2 // Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/Shaarli-API-test
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.
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/>.
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"
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
)) }
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 {
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'
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
80 // test if we're running cli
81 if len(os
.Args
) == 1 {
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())
95 if pwd
, isset
:= usr
.Password(); isset
{
96 req
.SetBasicAuth(usr
.Username(), pwd
)
98 bin
:= filepath
.Base(os
.Args
[0])
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
)
114 func (w reqWri
) Header() http
.Header
{
117 func (w reqWri
) Write(b
[]byte) (int, error
) {
120 func (w reqWri
) WriteHeader(statusCode
int) {
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
, " "))
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) {
138 for i
, v
:= range atts
{
143 xml
.EscapeText(w
, []byte(v
))
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")
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
)
168 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
171 client
:= http
.Client
{Jar
: jar
}
176 base
.Path
= path
.Join(base
.Path
[0:len(base
.Path
)-len(path_info
)], "about")
177 http
.Redirect(w
, r
, base
.Path
, http
.StatusFound
)
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
)
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
)
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()
200 http
.Error(w
, "Basic Pre-Authentication required.", http
.StatusUnauthorized
)
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
)
210 params
:= r
.URL
.Query()
211 if 1 != len(params
["url"]) {
212 http
.Error(w
, "Required parameter missing: url", http
.StatusBadRequest
)
215 p_url
:= params
["url"][0]
218 if 1 != len(params["description"]) {
219 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
222 p_description := params["description"][0]
225 if 1 == len(params["extended"]) {
226 p_extended = params["extended"][0]
230 if 1 == len(params["tags"]) {
231 p_tags = params["tags"][0]
237 base
.RawQuery
= v
.Encode()
239 resp
, err
:= client
.Get(base
.String())
241 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
244 formLogi
, err
:= formValuesFromReader(resp
.Body
, "loginform")
247 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
251 formLogi
.Set("login", uid
)
252 formLogi
.Set("password", pwd
)
253 resp
, err
= client
.PostForm(resp
.Request
.URL
.String(), formLogi
)
255 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
259 formLink
, err
:= formValuesFromReader(resp
.Body
, "linkform")
262 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
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
)
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?
275 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
282 "dt", tim
.Format(IsoDate
),
283 "tag", fv("lf_tags"))
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
),
295 case "/v1/posts/add":
296 // extract parameters
297 // agent := r.Header.Get("User-Agent")
300 uid
, pwd
, ok
:= r
.BasicAuth()
302 http
.Error(w
, "Basic Pre-Authentication required.", http
.StatusUnauthorized
)
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
)
312 params
:= r
.URL
.Query()
313 if 1 != len(params
["url"]) {
314 http
.Error(w
, "Required parameter missing: url", http
.StatusBadRequest
)
317 p_url
:= params
["url"][0]
319 if 1 != len(params
["description"]) {
320 http
.Error(w
, "Required parameter missing: description", http
.StatusBadRequest
)
323 p_description
:= params
["description"][0]
326 if 1 == len(params
["extended"]) {
327 p_extended
= params
["extended"][0]
331 if 1 == len(params
["tags"]) {
332 p_tags
= params
["tags"][0]
337 v
.Set("title", p_description
)
338 base
.RawQuery
= v
.Encode()
340 resp
, err
:= client
.Get(base
.String())
342 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
345 formLogi
, err
:= formValuesFromReader(resp
.Body
, "loginform")
348 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
352 formLogi
.Set("login", uid
)
353 formLogi
.Set("password", pwd
)
354 resp
, err
= client
.PostForm(resp
.Request
.URL
.String(), formLogi
)
356 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
360 formLink
, err
:= formValuesFromReader(resp
.Body
, "linkform")
363 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
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
)
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
)
378 formLink
.Del("lf_private")
380 formLink
.Set("lf_private", "lf_private")
383 resp
, err
= client
.PostForm(resp
.Request
.URL
.String(), formLink
)
385 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
395 case "/v1/posts/delete":
396 _
, _
, ok
:= r
.BasicAuth()
398 http
.Error(w
, "Basic Pre-Authentication required.", http
.StatusUnauthorized
)
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
)
408 params
:= r
.URL
.Query()
409 if 1 != len(params
["url"]) {
410 http
.Error(w
, "Required parameter missing: url", http
.StatusBadRequest
)
413 // p_url := params["url"][0]
416 "code", "not implemented yet")
418 case "/v1/posts/update":
419 _
, _
, ok
:= r
.BasicAuth()
421 http
.Error(w
, "Basic Pre-Authentication required.", http
.StatusUnauthorized
)
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
)
433 "time", "2011-03-24T19:02:07Z")
436 case "/v1/posts/recent",
443 "/v1/user/api_token",
446 http
.Error(w
, "Not Implemented", http
.StatusNotImplemented
)
452 func formValuesFromReader(r io
.Reader
, name
string) (ret url
.Values
, err error
) {
453 root
, err
:= html
.Parse(r
) // assumes r is UTF8
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"))
463 for _
, inp
:= range scrape
.FindAll(form
, func(n
*html
.Node
) bool {
464 return atom
.Input
== n
.DataAtom || atom
.Textarea
== n
.DataAtom
466 n
:= scrape
.Attr(inp
, "name")
468 n
= scrape
.Attr(inp
, "id")
471 ty
:= scrape
.Attr(inp
, "type")
472 v
:= scrape
.Attr(inp
, "value")
473 if atom
.Textarea
== inp
.DataAtom
{
475 } else if v
== "" && ty
== "checkbox" {
476 v
= scrape
.Attr(inp
, "checked")
480 return ret
, err
// return on first occurence