2 // Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/pinboard4shaarli
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/>.
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"
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
)) }
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)
70 // - extract parameters
71 // - call api backend method
74 h
:= handleMux(os
.Getenv("PATH_INFO"))
75 if err
:= cgi
.Serve(http
.TimeoutHandler(h
, 5*time
.Second
, "🐌")); err
!= nil {
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'
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
89 // test if we're running cli
90 if len(os
.Args
) == 1 {
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())
104 if pwd
, isset
:= usr
.Password(); isset
{
105 req
.SetBasicAuth(usr
.Username(), pwd
)
107 bin
:= filepath
.Base(os
.Args
[0])
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
)
123 func (w reqWri
) Header() http
.Header
{
126 func (w reqWri
) Write(b
[]byte) (int, error
) {
129 func (w reqWri
) WriteHeader(statusCode
int) {
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
, " "))
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
)
156 jar
, err
:= cookiejar
.New(&options
)
158 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
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
)
173 base
.Path
= path
.Join(base
.Path
[0:len(base
.Path
)-len(path_info
)], "..", "index.php")
177 http
.Redirect(w
, r
, "about", http
.StatusFound
)
180 asset("doap.rdf", "application/rdf+xml")
183 http
.Redirect(w
, r
, "v1/openapi.yaml", http
.StatusFound
)
185 case "/v1/openapi.yaml":
186 asset("openapi.yaml", "text/x-yaml; charset=utf-8")
189 // now comes the /real/ API
192 // pretend to add, but don't actually do it, but return the form preset values.
193 uid
, pwd
, ok
:= r
.BasicAuth()
195 http
.Error(w
, "Basic Pre-Authentication required.", http
.StatusForbidden
)
199 params
:= r
.URL
.Query()
200 if 1 != len(params
["url"]) {
201 http
.Error(w
, "Required parameter missing: url", http
.StatusBadRequest
)
204 p_url
:= params
["url"][0]
207 if 1 != len(params["description"]) {
208 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
211 p_description := params["description"][0]
214 if 1 == len(params["extended"]) {
215 p_extended = params["extended"][0]
219 if 1 == len(params["tags"]) {
220 p_tags = params["tags"][0]
226 base
.RawQuery
= v
.Encode()
228 req
, err
:= http
.NewRequest(http
.MethodGet
, base
.String(), nil)
230 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
233 req
.Header
.Set(http
.CanonicalHeaderKey("User-Agent"), agent
)
234 resp
, err
:= client
.Do(req
)
236 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
239 formLogi
, err
:= formValuesFromReader(resp
.Body
, "loginform")
242 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
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())))
251 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
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)
259 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
263 formLink
, err
:= formValuesFromReader(resp
.Body
, "linkform")
266 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
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
)
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?
279 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
283 w
.Write([]byte(xml
.Header
))
286 Dt
: tim
.Format(IsoDate
),
291 Hash
: fv("lf_linkdate"),
292 Description
: fv("lf_title"),
293 Extended
: fv("lf_description"),
295 Time
: tim
.Format(time
.RFC3339
),
299 enc
:= xml
.NewEncoder(w
)
306 // extract parameters
307 // agent := r.Header.Get("User-Agent")
310 uid
, pwd
, ok
:= r
.BasicAuth()
312 http
.Error(w
, "Basic Pre-Authentication required.", http
.StatusForbidden
)
316 params
:= r
.URL
.Query()
317 if 1 != len(params
["url"]) {
318 http
.Error(w
, "Required parameter missing: url", http
.StatusBadRequest
)
321 p_url
:= params
["url"][0]
323 if 1 != len(params
["description"]) {
324 http
.Error(w
, "Required parameter missing: description", http
.StatusBadRequest
)
327 p_description
:= params
["description"][0]
330 if 1 == len(params
["extended"]) {
331 p_extended
= params
["extended"][0]
335 if 1 == len(params
["tags"]) {
336 p_tags
= params
["tags"][0]
341 v
.Set("title", p_description
)
342 base
.RawQuery
= v
.Encode()
344 req
, err
:= http
.NewRequest(http
.MethodGet
, base
.String(), nil)
346 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
349 req
.Header
.Set(http
.CanonicalHeaderKey("User-Agent"), agent
)
350 resp
, err
:= client
.Do(req
)
352 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
355 formLogi
, err
:= formValuesFromReader(resp
.Body
, "loginform")
358 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
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())))
367 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
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)
375 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
379 formLink
, err
:= formValuesFromReader(resp
.Body
, "linkform")
382 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
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
)
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
)
397 formLink
.Del("lf_private")
399 formLink
.Set("lf_private", "lf_private")
402 req
, err
= http
.NewRequest(http
.MethodPost
, resp
.Request
.URL
.String(), bytes
.NewReader([]byte(formLink
.Encode())))
404 http
.Error(w
, err
.Error(), http
.StatusInternalServerError
)
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)
412 http
.Error(w
, err
.Error(), http
.StatusBadGateway
)
417 w
.Write([]byte(xml
.Header
))
418 pp
:= Result
{Code
: "done"}
419 enc
:= xml
.NewEncoder(w
)
426 _
, _
, ok
:= r
.BasicAuth()
428 http
.Error(w
, "Basic Pre-Authentication required.", http
.StatusUnauthorized
)
432 params
:= r
.URL
.Query()
433 if 1 != len(params
["url"]) {
434 http
.Error(w
, "Required parameter missing: url", http
.StatusBadRequest
)
437 // p_url := params["url"][0]
439 w
.Write([]byte(xml
.Header
))
440 pp
:= Result
{Code
: "not implemented yet"}
441 enc
:= xml
.NewEncoder(w
)
454 "/v1/user/api_token",
457 http
.Error(w
, "Not Implemented", http
.StatusNotImplemented
)
464 func formValuesFromReader(r io
.Reader
, name
string) (ret url
.Values
, err error
) {
465 root
, err
:= html
.Parse(r
) // assumes r is UTF8
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"))
475 for _
, inp
:= range scrape
.FindAll(form
, func(n
*html
.Node
) bool {
476 return atom
.Input
== n
.DataAtom || atom
.Textarea
== n
.DataAtom
478 n
:= scrape
.Attr(inp
, "name")
480 n
= scrape
.Attr(inp
, "id")
483 ty
:= scrape
.Attr(inp
, "type")
484 v
:= scrape
.Attr(inp
, "value")
485 if atom
.Textarea
== inp
.DataAtom
{
487 } else if v
== "" && ty
== "checkbox" {
488 v
= scrape
.Attr(inp
, "checked")
492 return ret
, err
// return on first occurence