🐳.
[pin4sha_cgi.git] / pinboard.go
blob1ed6ade4d5084a61e73897d293559b53a4cdea94
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 "fmt"
23 "io"
24 "log"
25 "net/http"
26 "net/http/cgi"
27 "net/http/cookiejar"
28 "net/url"
29 "os"
30 "path"
31 "path/filepath"
32 "strings"
33 "time"
35 "github.com/yhat/scrape"
36 "golang.org/x/net/html"
37 "golang.org/x/net/html/atom"
38 // "golang.org/x/net/html/charset"
39 "golang.org/x/net/publicsuffix"
42 const (
43 ShaarliDate = "20060102_150405"
44 IsoDate = "2006-01-02"
47 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
49 // even cooler: https://stackoverflow.com/a/8363629
51 // inspired by // https://coderwall.com/p/cp5fya/measuring-execution-time-in-go
52 func trace(name string) (string, time.Time) { return name, time.Now() }
53 func un(name string, start time.Time) { log.Printf("%s took %s", name, time.Since(start)) }
55 func main() {
56 if cli() {
57 return
60 if true {
61 // lighttpd doesn't seem to like more than one (per-vhost) server.breakagelog
62 log.SetOutput(os.Stderr)
63 } else { // log to custom logfile rather than stderr (may not be reachable on shared hosting)
66 if err := cgi.Serve(http.HandlerFunc(handleMux)); err != nil {
67 log.Fatal(err)
71 /// $ ./pinboard4shaarli.cgi --help | -h | -?
72 /// $ ./pinboard4shaarli.cgi https://demo.shaarli.org/pinboard4shaarli.cgi/v1/about
73 /// $ ./pinboard4shaarli.cgi 'https://uid:pwd@demo.shaarli.org/pinboard4shaarli.cgi/v1/posts/get?url=http://m.heise.de/12'
74 /// $ ./pinboard4shaarli.cgi 'https://uid:pwd@demo.shaarli.org/pinboard4shaarli.cgi/v1/posts/add?url=http://m.heise.de/12&description=foo'
75 /// todo
76 /// $ ./pinboard4shaarli.cgi https://uid:pwd@demo.shaarli.org/pinboard4shaarli.cgi/v1/user/api_token
77 /// $ ./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
78 ///
79 func cli() bool {
80 // test if we're running cli
81 if len(os.Args) == 1 {
82 return false
85 for i, a := range os.Args[2:] {
86 fmt.Fprintf(os.Stderr, " %d: %s\n", i, a)
89 // todo?: add parameters
91 if req, err := http.NewRequest(http.MethodGet, os.Args[1], nil); err != nil {
92 fmt.Fprintf(os.Stderr, "%s\n", err.Error())
93 } else {
94 usr := req.URL.User
95 if pwd, isset := usr.Password(); isset {
96 req.SetBasicAuth(usr.Username(), pwd)
98 bin := filepath.Base(os.Args[0])
99 str := req.URL.Path
100 idx := strings.LastIndex(str, bin)
101 os.Setenv("PATH_INFO", str[idx+len(bin):])
102 handleMux(reqWri{r: req, f: os.Stderr, h: http.Header{}}, req)
105 return true
108 type reqWri struct {
109 r *http.Request
110 f io.Writer
111 h http.Header
114 func (w reqWri) Header() http.Header {
115 return w.h
117 func (w reqWri) Write(b []byte) (int, error) {
118 return w.f.Write(b)
120 func (w reqWri) WriteHeader(statusCode int) {
121 const LF = "\r\n"
122 fmt.Fprintf(w.f, "%s %d %s"+LF, w.r.Proto, statusCode, http.StatusText(statusCode))
123 for k, v := range w.Header() {
124 fmt.Fprintf(w.f, "%s: %s"+LF, k, strings.Join(v, " "))
126 fmt.Fprintf(w.f, LF)
129 // https://pinboard.in/api
130 func handleMux(w http.ResponseWriter, r *http.Request) {
131 raw := func(s ...string) {
132 for _, txt := range s {
133 io.WriteString(w, txt)
136 elmS := func(e string, close bool, atts ...string) {
137 raw("<", e)
138 for i, v := range atts {
139 if i%2 == 0 {
140 raw(" ", v, "=")
141 } else {
142 raw("'")
143 xml.EscapeText(w, []byte(v))
144 raw("'")
147 if close {
148 raw(" /")
150 raw(">", "\n")
152 elmE := func(e string) { raw("</", e, ">", "\n") }
154 defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
155 path_info := os.Getenv("PATH_INFO")
156 base := *r.URL
157 base.Path = path.Join(base.Path[0:len(base.Path)-len(path_info)], "..", "index.php")
159 w.Header().Set(http.CanonicalHeaderKey("X-Powered-By"), strings.Join([]string{"https://code.mro.name/mro/pinboard4shaarli", "#", version, "+", GitSHA1}, ""))
160 w.Header().Set(http.CanonicalHeaderKey("Content-Type"), "text/xml; charset=utf-8")
162 // https://stackoverflow.com/a/18414432
163 options := cookiejar.Options{
164 PublicSuffixList: publicsuffix.List,
166 jar, err := cookiejar.New(&options)
167 if err != nil {
168 http.Error(w, err.Error(), http.StatusInternalServerError)
169 return
171 client := http.Client{Jar: jar}
173 switch path_info {
174 case "":
175 base := *r.URL
176 base.Path = path.Join(base.Path[0:len(base.Path)-len(path_info)], "about")
177 http.Redirect(w, r, base.Path, http.StatusFound)
179 return
180 case "/about":
181 w.Header().Set(http.CanonicalHeaderKey("Content-Type"), "application/rdf+xml")
182 if b, err := Asset("doap.rdf"); err != nil {
183 http.Error(w, err.Error(), http.StatusInternalServerError)
184 } else {
185 w.Write(b)
187 return
188 case "/v1/openapi.yaml":
189 w.Header().Set(http.CanonicalHeaderKey("Content-Type"), "text/x-yaml; charset=utf-8")
190 if b, err := Asset("openapi.yaml"); err != nil {
191 http.Error(w, err.Error(), http.StatusInternalServerError)
192 } else {
193 w.Write(b)
195 return
196 case "/v1/posts/get":
197 // pretend to add, but don't actually do it, but return the form preset values.
198 uid, pwd, ok := r.BasicAuth()
199 if !ok {
200 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
201 return
204 if http.MethodGet != r.Method {
205 w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
206 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
207 return
210 params := r.URL.Query()
211 if 1 != len(params["url"]) {
212 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
213 return
215 p_url := params["url"][0]
218 if 1 != len(params["description"]) {
219 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
220 return
222 p_description := params["description"][0]
224 p_extended := ""
225 if 1 == len(params["extended"]) {
226 p_extended = params["extended"][0]
229 p_tags := ""
230 if 1 == len(params["tags"]) {
231 p_tags = params["tags"][0]
235 v := url.Values{}
236 v.Set("post", p_url)
237 base.RawQuery = v.Encode()
239 resp, err := client.Get(base.String())
240 if err != nil {
241 http.Error(w, err.Error(), http.StatusBadGateway)
242 return
244 formLogi, err := formValuesFromReader(resp.Body, "loginform")
245 resp.Body.Close()
246 if err != nil {
247 http.Error(w, err.Error(), http.StatusInternalServerError)
248 return
251 formLogi.Set("login", uid)
252 formLogi.Set("password", pwd)
253 resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
254 if err != nil {
255 http.Error(w, err.Error(), http.StatusBadGateway)
256 return
259 formLink, err := formValuesFromReader(resp.Body, "linkform")
260 resp.Body.Close()
261 if err != nil {
262 http.Error(w, err.Error(), http.StatusBadGateway)
263 return
265 // if we do not have a linkform, auth must have failed.
266 if 0 == len(formLink) {
267 http.Error(w, "Authentication failed", http.StatusForbidden)
268 return
271 fv := func(s string) string { return formLink.Get(s) }
273 tim, err := time.ParseInLocation(ShaarliDate, fv("lf_linkdate"), time.Local) // can we do any better?
274 if err != nil {
275 http.Error(w, err.Error(), http.StatusBadGateway)
276 return
279 raw(xml.Header)
280 elmS("posts", false,
281 "user", uid,
282 "dt", tim.Format(IsoDate),
283 "tag", fv("lf_tags"))
284 elmS("post", true,
285 "href", fv("lf_url"),
286 "hash", fv("lf_linkdate"),
287 "description", fv("lf_title"),
288 "extended", fv("lf_description"),
289 "tag", fv("lf_tags"),
290 "time", tim.Format(time.RFC3339),
291 "others", "0")
292 elmE("posts")
294 return
295 case "/v1/posts/add":
296 // extract parameters
297 // agent := r.Header.Get("User-Agent")
298 shared := true
300 uid, pwd, ok := r.BasicAuth()
301 if !ok {
302 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
303 return
306 if http.MethodGet != r.Method {
307 w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
308 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
309 return
312 params := r.URL.Query()
313 if 1 != len(params["url"]) {
314 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
315 return
317 p_url := params["url"][0]
319 if 1 != len(params["description"]) {
320 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
321 return
323 p_description := params["description"][0]
325 p_extended := ""
326 if 1 == len(params["extended"]) {
327 p_extended = params["extended"][0]
330 p_tags := ""
331 if 1 == len(params["tags"]) {
332 p_tags = params["tags"][0]
335 v := url.Values{}
336 v.Set("post", p_url)
337 v.Set("title", p_description)
338 base.RawQuery = v.Encode()
340 resp, err := client.Get(base.String())
341 if err != nil {
342 http.Error(w, err.Error(), http.StatusBadGateway)
343 return
345 formLogi, err := formValuesFromReader(resp.Body, "loginform")
346 resp.Body.Close()
347 if err != nil {
348 http.Error(w, err.Error(), http.StatusInternalServerError)
349 return
352 formLogi.Set("login", uid)
353 formLogi.Set("password", pwd)
354 resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
355 if err != nil {
356 http.Error(w, err.Error(), http.StatusBadGateway)
357 return
360 formLink, err := formValuesFromReader(resp.Body, "linkform")
361 resp.Body.Close()
362 if err != nil {
363 http.Error(w, err.Error(), http.StatusBadGateway)
364 return
366 // if we do not have a linkform, auth must have failed.
367 if 0 == len(formLink) {
368 http.Error(w, "Authentication failed", http.StatusForbidden)
369 return
372 // formLink.Set("lf_linkdate", ShaarliDate)
373 // formLink.Set("lf_url", p_url)
374 // formLink.Set("lf_title", p_description)
375 formLink.Set("lf_description", p_extended)
376 formLink.Set("lf_tags", p_tags)
377 if shared {
378 formLink.Del("lf_private")
379 } else {
380 formLink.Set("lf_private", "lf_private")
383 resp, err = client.PostForm(resp.Request.URL.String(), formLink)
384 if err != nil {
385 http.Error(w, err.Error(), http.StatusBadGateway)
386 return
388 resp.Body.Close()
390 raw(xml.Header)
391 elmS("result", true,
392 "code", "done")
394 return
395 case "/v1/posts/delete":
396 _, _, ok := r.BasicAuth()
397 if !ok {
398 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
399 return
402 if http.MethodGet != r.Method {
403 w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
404 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
405 return
408 params := r.URL.Query()
409 if 1 != len(params["url"]) {
410 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
411 return
413 // p_url := params["url"][0]
415 elmS("result", true,
416 "code", "not implemented yet")
417 return
418 case "/v1/posts/update":
419 _, _, ok := r.BasicAuth()
420 if !ok {
421 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
422 return
425 if http.MethodGet != r.Method {
426 w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
427 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
428 return
431 raw(xml.Header)
432 elmS("update", true,
433 "time", "2011-03-24T19:02:07Z")
434 return
436 case "/v1/posts/recent",
437 "/v1/posts/dates",
438 "/v1/posts/suggest",
439 "/v1/tags/get",
440 "/v1/tags/delete",
441 "/v1/tags/rename",
442 "/v1/user/secret",
443 "/v1/user/api_token",
444 "/v1/notes/list",
445 "/v1/notes/ID":
446 http.Error(w, "Not Implemented", http.StatusNotImplemented)
447 return
449 http.NotFound(w, r)
452 func formValuesFromReader(r io.Reader, name string) (ret url.Values, err error) {
453 root, err := html.Parse(r) // assumes r is UTF8
454 if err != nil {
455 return ret, err
458 for _, form := range scrape.FindAll(root, func(n *html.Node) bool {
459 return atom.Form == n.DataAtom &&
460 (name == scrape.Attr(n, "name") || name == scrape.Attr(n, "id"))
461 }) {
462 ret := url.Values{}
463 for _, inp := range scrape.FindAll(form, func(n *html.Node) bool {
464 return atom.Input == n.DataAtom || atom.Textarea == n.DataAtom
465 }) {
466 n := scrape.Attr(inp, "name")
467 if n == "" {
468 n = scrape.Attr(inp, "id")
471 ty := scrape.Attr(inp, "type")
472 v := scrape.Attr(inp, "value")
473 if atom.Textarea == inp.DataAtom {
474 v = scrape.Text(inp)
475 } else if v == "" && ty == "checkbox" {
476 v = scrape.Attr(inp, "checked")
478 ret.Set(n, v)
480 return ret, err // return on first occurence
482 return ret, err