Merge branch 'pinboard.in/go'
[pin4sha_cgi.git] / pinboard.go
blob56dd9f0eaa362b92cdf949e57776be709d32225c
1 //
2 // Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/pinboard4shaarli
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 "bytes"
22 "encoding/xml"
23 "fmt"
24 "io"
25 "log"
26 "net/http"
27 "net/http/cgi"
28 "net/http/cookiejar"
29 "net/url"
30 "os"
31 "path"
32 "path/filepath"
33 "strings"
34 "time"
36 "github.com/yhat/scrape"
37 "golang.org/x/net/html"
38 "golang.org/x/net/html/atom"
39 // "golang.org/x/net/html/charset"
40 "golang.org/x/net/publicsuffix"
43 const (
44 ShaarliDate = "20060102_150405"
45 IsoDate = "2006-01-02"
48 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
50 // even cooler: https://stackoverflow.com/a/8363629
52 // inspired by // https://coderwall.com/p/cp5fya/measuring-execution-time-in-go
53 func trace(name string) (string, time.Time) { return name, time.Now() }
54 func un(name string, start time.Time) { log.Printf("%s took %s", name, time.Since(start)) }
56 func main() {
57 if cli() {
58 return
61 if true {
62 // lighttpd doesn't seem to like more than one (per-vhost) server.breakagelog
63 log.SetOutput(os.Stderr)
64 } else { // log to custom logfile rather than stderr (may not be reachable on shared hosting)
67 // - http.StripPrefix (and just keep PATH_INFO as Request.URL.path)
68 // - route
69 // - authenticate
70 // - extract parameters
71 // - call api backend method
72 // - build response
74 h := handleMux(os.Getenv("PATH_INFO"))
75 if err := cgi.Serve(http.TimeoutHandler(h, 5*time.Second, "🐌")); err != nil {
76 log.Fatal(err)
80 /// $ ./pinboard4shaarli.cgi --help | -h | -?
81 /// $ ./pinboard4shaarli.cgi https://demo.shaarli.org/pinboard4shaarli.cgi/v1/about
82 /// $ ./pinboard4shaarli.cgi 'https://uid:pwd@demo.shaarli.org/pinboard4shaarli.cgi/v1/posts/get?url=http://m.heise.de/12'
83 /// $ ./pinboard4shaarli.cgi 'https://uid:pwd@demo.shaarli.org/pinboard4shaarli.cgi/v1/posts/add?url=http://m.heise.de/12&description=foo'
84 /// todo
85 /// $ ./pinboard4shaarli.cgi https://uid:pwd@demo.shaarli.org/pinboard4shaarli.cgi/v1/user/api_token
86 /// $ ./pinboard4shaarli.cgi --data-urlencode auth_token=uid:XYZUUU --data-urlencode url=https://m.heise.de/foo https://demo.shaarli.org/pinboard4shaarli.cgi/v1/posts/get
87 ///
88 func cli() bool {
89 // test if we're running cli
90 if len(os.Args) == 1 {
91 return false
94 for i, a := range os.Args[2:] {
95 fmt.Fprintf(os.Stderr, " %d: %s\n", i, a)
98 // todo?: add parameters
100 if req, err := http.NewRequest(http.MethodGet, os.Args[1], nil); err != nil {
101 fmt.Fprintf(os.Stderr, "%s\n", err.Error())
102 } else {
103 usr := req.URL.User
104 if pwd, isset := usr.Password(); isset {
105 req.SetBasicAuth(usr.Username(), pwd)
107 bin := filepath.Base(os.Args[0])
108 str := req.URL.Path
109 idx := strings.LastIndex(str, bin)
110 pi := str[idx+len(bin):]
111 handleMux(pi)(reqWri{r: req, f: os.Stderr, h: http.Header{}}, req)
114 return true
117 type reqWri struct {
118 r *http.Request
119 f io.Writer
120 h http.Header
123 func (w reqWri) Header() http.Header {
124 return w.h
126 func (w reqWri) Write(b []byte) (int, error) {
127 return w.f.Write(b)
129 func (w reqWri) WriteHeader(statusCode int) {
130 const LF = "\r\n"
131 fmt.Fprintf(w.f, "%s %d %s"+LF, w.r.Proto, statusCode, http.StatusText(statusCode))
132 for k, v := range w.Header() {
133 fmt.Fprintf(w.f, "%s: %s"+LF, k, strings.Join(v, " "))
135 fmt.Fprintf(w.f, LF)
138 // https://pinboard.in/api
139 func handleMux(path_info string) http.HandlerFunc {
140 agent := strings.Join([]string{"https://code.mro.name/mro/pinboard4shaarli", "#", version, "+", GitSHA1}, "")
141 // https://stackoverflow.com/a/18414432
142 options := cookiejar.Options{PublicSuffixList: publicsuffix.List}
144 return func(w http.ResponseWriter, r *http.Request) {
145 defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
147 w.Header().Set(http.CanonicalHeaderKey("X-Powered-By"), agent)
148 w.Header().Set(http.CanonicalHeaderKey("Content-Type"), "text/xml; charset=utf-8")
150 if http.MethodGet != r.Method {
151 w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
152 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
153 return
156 jar, err := cookiejar.New(&options)
157 if err != nil {
158 http.Error(w, err.Error(), http.StatusInternalServerError)
159 return
161 client := http.Client{Jar: jar, Timeout: 2 * time.Second}
163 asset := func(name, mime string) {
164 w.Header().Set(http.CanonicalHeaderKey("Content-Type"), mime)
165 if b, err := Asset(name); err != nil {
166 http.Error(w, err.Error(), http.StatusInternalServerError)
167 } else {
168 w.Write(b)
172 base := *r.URL
173 base.Path = path.Join(base.Path[0:len(base.Path)-len(path_info)], "..", "index.php")
175 switch path_info {
176 case "":
177 http.Redirect(w, r, "about", http.StatusFound)
178 return
179 case "/about":
180 asset("doap.rdf", "application/rdf+xml")
181 return
182 case "/v1":
183 http.Redirect(w, r, "v1/openapi.yaml", http.StatusFound)
184 return
185 case "/v1/openapi.yaml":
186 asset("openapi.yaml", "text/x-yaml; charset=utf-8")
187 return
189 // now comes the /real/ API
190 case
191 "/v1/posts/get":
192 // pretend to add, but don't actually do it, but return the form preset values.
193 uid, pwd, ok := r.BasicAuth()
194 if !ok {
195 http.Error(w, "Basic Pre-Authentication required.", http.StatusForbidden)
196 return
199 params := r.URL.Query()
200 if 1 != len(params["url"]) {
201 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
202 return
204 p_url := params["url"][0]
207 if 1 != len(params["description"]) {
208 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
209 return
211 p_description := params["description"][0]
213 p_extended := ""
214 if 1 == len(params["extended"]) {
215 p_extended = params["extended"][0]
218 p_tags := ""
219 if 1 == len(params["tags"]) {
220 p_tags = params["tags"][0]
224 v := url.Values{}
225 v.Set("post", p_url)
226 base.RawQuery = v.Encode()
228 req, err := http.NewRequest(http.MethodGet, base.String(), nil)
229 if err != nil {
230 http.Error(w, err.Error(), http.StatusInternalServerError)
231 return
233 req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
234 resp, err := client.Do(req)
235 if err != nil {
236 http.Error(w, err.Error(), http.StatusBadGateway)
237 return
239 formLogi, err := formValuesFromReader(resp.Body, "loginform")
240 resp.Body.Close()
241 if err != nil {
242 http.Error(w, err.Error(), http.StatusInternalServerError)
243 return
246 formLogi.Set("login", uid)
247 formLogi.Set("password", pwd)
249 req, err = http.NewRequest(http.MethodPost, resp.Request.URL.String(), bytes.NewReader([]byte(formLogi.Encode())))
250 if err != nil {
251 http.Error(w, err.Error(), http.StatusInternalServerError)
252 return
254 req.Header.Set(http.CanonicalHeaderKey("Content-Type"), "application/x-www-form-urlencoded")
255 req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
256 resp, err = client.Do(req)
257 // resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
258 if err != nil {
259 http.Error(w, err.Error(), http.StatusBadGateway)
260 return
263 formLink, err := formValuesFromReader(resp.Body, "linkform")
264 resp.Body.Close()
265 if err != nil {
266 http.Error(w, err.Error(), http.StatusBadGateway)
267 return
269 // if we do not have a linkform, auth must have failed.
270 if 0 == len(formLink) {
271 http.Error(w, "Authentication failed", http.StatusForbidden)
272 return
275 fv := func(s string) string { return formLink.Get(s) }
277 tim, err := time.ParseInLocation(ShaarliDate, fv("lf_linkdate"), time.Local) // can we do any better?
278 if err != nil {
279 http.Error(w, err.Error(), http.StatusBadGateway)
280 return
283 w.Write([]byte(xml.Header))
284 pp := Posts{
285 User: uid,
286 Dt: tim.Format(IsoDate),
287 Tag: fv("lf_tags"),
288 Posts: []Post{
289 Post{
290 Href: fv("lf_url"),
291 Hash: fv("lf_linkdate"),
292 Description: fv("lf_title"),
293 Extended: fv("lf_description"),
294 Tag: fv("lf_tags"),
295 Time: tim.Format(time.RFC3339),
299 enc := xml.NewEncoder(w)
300 enc.Encode(pp)
301 enc.Flush()
303 return
304 case
305 "/v1/posts/add":
306 // extract parameters
307 // agent := r.Header.Get("User-Agent")
308 shared := true
310 uid, pwd, ok := r.BasicAuth()
311 if !ok {
312 http.Error(w, "Basic Pre-Authentication required.", http.StatusForbidden)
313 return
316 params := r.URL.Query()
317 if 1 != len(params["url"]) {
318 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
319 return
321 p_url := params["url"][0]
323 if 1 != len(params["description"]) {
324 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
325 return
327 p_description := params["description"][0]
329 p_extended := ""
330 if 1 == len(params["extended"]) {
331 p_extended = params["extended"][0]
334 p_tags := ""
335 if 1 == len(params["tags"]) {
336 p_tags = params["tags"][0]
339 v := url.Values{}
340 v.Set("post", p_url)
341 v.Set("title", p_description)
342 base.RawQuery = v.Encode()
344 req, err := http.NewRequest(http.MethodGet, base.String(), nil)
345 if err != nil {
346 http.Error(w, err.Error(), http.StatusInternalServerError)
347 return
349 req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
350 resp, err := client.Do(req)
351 if err != nil {
352 http.Error(w, err.Error(), http.StatusBadGateway)
353 return
355 formLogi, err := formValuesFromReader(resp.Body, "loginform")
356 resp.Body.Close()
357 if err != nil {
358 http.Error(w, err.Error(), http.StatusInternalServerError)
359 return
362 formLogi.Set("login", uid)
363 formLogi.Set("password", pwd)
365 req, err = http.NewRequest(http.MethodPost, resp.Request.URL.String(), bytes.NewReader([]byte(formLogi.Encode())))
366 if err != nil {
367 http.Error(w, err.Error(), http.StatusInternalServerError)
368 return
370 req.Header.Set(http.CanonicalHeaderKey("Content-Type"), "application/x-www-form-urlencoded")
371 req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
372 resp, err = client.Do(req)
373 // resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
374 if err != nil {
375 http.Error(w, err.Error(), http.StatusBadGateway)
376 return
379 formLink, err := formValuesFromReader(resp.Body, "linkform")
380 resp.Body.Close()
381 if err != nil {
382 http.Error(w, err.Error(), http.StatusBadGateway)
383 return
385 // if we do not have a linkform, auth must have failed.
386 if 0 == len(formLink) {
387 http.Error(w, "Authentication failed", http.StatusForbidden)
388 return
391 // formLink.Set("lf_linkdate", ShaarliDate)
392 // formLink.Set("lf_url", p_url)
393 // formLink.Set("lf_title", p_description)
394 formLink.Set("lf_description", p_extended)
395 formLink.Set("lf_tags", p_tags)
396 if shared {
397 formLink.Del("lf_private")
398 } else {
399 formLink.Set("lf_private", "lf_private")
402 req, err = http.NewRequest(http.MethodPost, resp.Request.URL.String(), bytes.NewReader([]byte(formLink.Encode())))
403 if err != nil {
404 http.Error(w, err.Error(), http.StatusInternalServerError)
405 return
407 req.Header.Set(http.CanonicalHeaderKey("Content-Type"), "application/x-www-form-urlencoded")
408 req.Header.Set(http.CanonicalHeaderKey("User-Agent"), agent)
409 resp, err = client.Do(req)
410 // resp, err = client.PostForm(resp.Request.URL.String(), formLink)
411 if err != nil {
412 http.Error(w, err.Error(), http.StatusBadGateway)
413 return
415 resp.Body.Close()
417 w.Write([]byte(xml.Header))
418 pp := Result{Code: "done"}
419 enc := xml.NewEncoder(w)
420 enc.Encode(pp)
421 enc.Flush()
423 return
424 case
425 "/v1/posts/delete":
426 _, _, ok := r.BasicAuth()
427 if !ok {
428 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
429 return
432 params := r.URL.Query()
433 if 1 != len(params["url"]) {
434 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
435 return
437 // p_url := params["url"][0]
439 w.Write([]byte(xml.Header))
440 pp := Result{Code: "not implemented yet"}
441 enc := xml.NewEncoder(w)
442 enc.Encode(pp)
443 enc.Flush()
444 return
445 case
446 "/v1/notes/ID",
447 "/v1/notes/list",
448 "/v1/posts/dates",
449 "/v1/posts/suggest",
450 "/v1/posts/update",
451 "/v1/tags/delete",
452 "/v1/tags/get",
453 "/v1/tags/rename",
454 "/v1/user/api_token",
455 "/v1/user/secret",
456 "/v1/posts/recent":
457 http.Error(w, "Not Implemented", http.StatusNotImplemented)
458 return
460 http.NotFound(w, r)
464 func formValuesFromReader(r io.Reader, name string) (ret url.Values, err error) {
465 root, err := html.Parse(r) // assumes r is UTF8
466 if err != nil {
467 return ret, err
470 for _, form := range scrape.FindAll(root, func(n *html.Node) bool {
471 return atom.Form == n.DataAtom &&
472 (name == scrape.Attr(n, "name") || name == scrape.Attr(n, "id"))
473 }) {
474 ret := url.Values{}
475 for _, inp := range scrape.FindAll(form, func(n *html.Node) bool {
476 return atom.Input == n.DataAtom || atom.Textarea == n.DataAtom
477 }) {
478 n := scrape.Attr(inp, "name")
479 if n == "" {
480 n = scrape.Attr(inp, "id")
483 ty := scrape.Attr(inp, "type")
484 v := scrape.Attr(inp, "value")
485 if atom.Textarea == inp.DataAtom {
486 v = scrape.Text(inp)
487 } else if v == "" && ty == "checkbox" {
488 v = scrape.Attr(inp, "checked")
490 ret.Set(n, v)
492 return ret, err // return on first occurence
494 return ret, err