https://github.com/shaarli/Shaarli/releases/tag/v0.11.0
[pin4sha_cgi.git] / pinboard.go
blobd6eaa392c1905e60ccbb50532c9d7afa624d9d0a
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 "path"
30 "strings"
31 "time"
33 "github.com/yhat/scrape"
34 "golang.org/x/net/html"
35 "golang.org/x/net/html/atom"
36 // "golang.org/x/net/html/charset"
37 "golang.org/x/net/publicsuffix"
40 const (
41 ShaarliDate = "20060102_150405"
42 IsoDate = "2006-01-02"
45 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
47 // even cooler: https://stackoverflow.com/a/8363629
49 // inspired by // https://coderwall.com/p/cp5fya/measuring-execution-time-in-go
50 func trace(name string) (string, time.Time) { return name, time.Now() }
51 func un(name string, start time.Time) { log.Printf("%s took %s", name, time.Since(start)) }
53 func main() {
54 if true {
55 // lighttpd doesn't seem to like more than one (per-vhost) server.breakagelog
56 log.SetOutput(os.Stderr)
57 } else { // log to custom logfile rather than stderr (may not be reachable on shared hosting)
60 if err := cgi.Serve(http.HandlerFunc(handleMux)); err != nil {
61 log.Fatal(err)
65 // https://pinboard.in/api
66 func handleMux(w http.ResponseWriter, r *http.Request) {
67 raw := func(s ...string) {
68 for _, txt := range s {
69 io.WriteString(w, txt)
72 elmS := func(e string, close bool, atts ...string) {
73 raw("<", e)
74 for i, v := range atts {
75 if i%2 == 0 {
76 raw(" ", v, "=")
77 } else {
78 raw("'")
79 xml.EscapeText(w, []byte(v))
80 raw("'")
83 if close {
84 raw(" /")
86 raw(">", "\n")
88 elmE := func(e string) { raw("</", e, ">", "\n") }
90 defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
91 path_info := os.Getenv("PATH_INFO")
92 base := *r.URL
93 base.Path = path.Join(base.Path[0:len(base.Path)-len(path_info)], "..", "index.php")
95 w.Header().Set(http.CanonicalHeaderKey("X-Powered-By"), strings.Join([]string{"https://code.mro.name/mro/Shaarli-API-test", "#", version, "+", GitSHA1}, ""))
96 w.Header().Set(http.CanonicalHeaderKey("Content-Type"), "text/xml; charset=utf-8")
98 // https://stackoverflow.com/a/18414432
99 options := cookiejar.Options{
100 PublicSuffixList: publicsuffix.List,
102 jar, err := cookiejar.New(&options)
103 if err != nil {
104 http.Error(w, err.Error(), http.StatusInternalServerError)
105 return
107 client := http.Client{Jar: jar}
109 switch path_info {
110 case "",
111 "/about":
112 base := *r.URL
113 base.Path = path.Join(base.Path[0:len(base.Path)-len(path_info)], "about") + "/"
114 http.Redirect(w, r, base.Path, http.StatusFound)
116 return
117 case "/about/":
118 // w.Header().Set(http.CanonicalHeaderKey("Content-Type"), "application/rdf+xml")
119 raw(`<?xml version="1.0" encoding="utf-8"?>
120 <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
121 xmlns="http://usefulinc.com/ns/doap#">
122 <Project>
123 <name xml:lang="en">🛠 Shaarli Pinboard API</name>
124 <short-description xml:lang="en">subset conforming https://pinboard.in/api/</short-description>
125 <implements rdf:resource="https://pinboard.in/api/"/>
126 <platform rdf:resource="https://sebsauvage.net/wiki/doku.php?id=php:shaarli"/>
127 <homepage rdf:resource="https://code.mro.name/mro/Shaarli-API-test/"/>
128 <bug-database rdf:resource="https://code.mro.name/mro/Shaarli-API-test/issues"/>
129 <wiki rdf:resource="https://code.mro.name/mro/Shaarli-API-test/wiki"/>
130 <license rdf:resource="https://code.mro.name/mro/Shaarli-API-test/src/master/LICENSE"/>
131 <maintainer rdf:resource="http://mro.name/~me"/>
132 <programming-language>golang</programming-language>
133 <category>microblogging</category>
134 <category>shaarli</category>
135 <category>nodb</category>
136 <category>api</category>
137 <category>pinboard</category>
138 <category>delicious</category>
139 <category>cgi</category>
140 <repository>
141 <GitRepository>
142 <browse rdf:resource="https://code.mro.name/mro/Shaarli-API-test"/>
143 <location rdf:resource="https://code.mro.name/mro/Shaarli-API-test.git"/>
144 </GitRepository>
145 </repository>
146 <release>
147 <Version>
148 <name>`, version, "+", GitSHA1, `</name>
149 <revision>`, GitSHA1, `</revision>
150 <description>…</description>
151 </Version>
152 </release>
153 </Project>
154 </rdf:RDF>`)
156 return
157 case "/v1/posts/add":
158 // extract parameters
159 // agent := r.Header.Get("User-Agent")
160 shared := true
162 uid, pwd, ok := r.BasicAuth()
163 if !ok {
164 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
165 return
168 if http.MethodGet != r.Method {
169 w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
170 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
171 return
174 params := r.URL.Query()
175 if 1 != len(params["url"]) {
176 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
177 return
179 p_url := params["url"][0]
181 if 1 != len(params["description"]) {
182 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
183 return
185 p_description := params["description"][0]
187 p_extended := ""
188 if 1 == len(params["extended"]) {
189 p_extended = params["extended"][0]
192 p_tags := ""
193 if 1 == len(params["tags"]) {
194 p_tags = params["tags"][0]
197 v := url.Values{}
198 v.Set("post", p_url)
199 v.Set("title", p_description)
200 base.RawQuery = v.Encode()
202 resp, err := client.Get(base.String())
203 if err != nil {
204 http.Error(w, err.Error(), http.StatusBadGateway)
205 return
207 formLogi, err := formValuesFromReader(resp.Body, "loginform")
208 resp.Body.Close()
209 if err != nil {
210 http.Error(w, err.Error(), http.StatusInternalServerError)
211 return
214 formLogi.Set("login", uid)
215 formLogi.Set("password", pwd)
216 resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
217 if err != nil {
218 http.Error(w, err.Error(), http.StatusBadGateway)
219 return
222 formLink, err := formValuesFromReader(resp.Body, "linkform")
223 resp.Body.Close()
224 if err != nil {
225 http.Error(w, err.Error(), http.StatusBadGateway)
226 return
228 // if we do not have a linkform, auth must have failed.
229 if 0 == len(formLink) {
230 http.Error(w, "Authentication failed", http.StatusForbidden)
231 return
234 // formLink.Set("lf_linkdate", ShaarliDate)
235 // formLink.Set("lf_url", p_url)
236 // formLink.Set("lf_title", p_description)
237 formLink.Set("lf_description", p_extended)
238 formLink.Set("lf_tags", p_tags)
239 if shared {
240 formLink.Del("lf_private")
241 } else {
242 formLink.Set("lf_private", "lf_private")
245 resp, err = client.PostForm(resp.Request.URL.String(), formLink)
246 if err != nil {
247 http.Error(w, err.Error(), http.StatusBadGateway)
248 return
250 resp.Body.Close()
252 raw(xml.Header)
253 elmS("result", true,
254 "code", "done")
256 return
257 case "/v1/posts/delete":
258 _, _, ok := r.BasicAuth()
259 if !ok {
260 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
261 return
264 if http.MethodGet != r.Method {
265 w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
266 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
267 return
270 params := r.URL.Query()
271 if 1 != len(params["url"]) {
272 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
273 return
275 // p_url := params["url"][0]
277 elmS("result", true,
278 "code", "not implemented yet")
279 return
280 case "/v1/posts/update":
281 _, _, ok := r.BasicAuth()
282 if !ok {
283 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
284 return
287 if http.MethodGet != r.Method {
288 w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
289 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
290 return
293 raw(xml.Header)
294 elmS("update", true,
295 "time", "2011-03-24T19:02:07Z")
296 return
297 case "/v1/posts/get":
298 // pretend to add, but don't actually do it, but return the form preset values.
299 uid, pwd, ok := r.BasicAuth()
300 if !ok {
301 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
302 return
305 if http.MethodGet != r.Method {
306 w.Header().Set(http.CanonicalHeaderKey("Allow"), http.MethodGet)
307 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
308 return
311 params := r.URL.Query()
312 if 1 != len(params["url"]) {
313 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
314 return
316 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]
336 v := url.Values{}
337 v.Set("post", p_url)
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 fv := func(s string) string { return formLink.Get(s) }
374 tim, err := time.ParseInLocation(ShaarliDate, fv("lf_linkdate"), time.Local) // can we do any better?
375 if err != nil {
376 http.Error(w, err.Error(), http.StatusBadGateway)
377 return
380 raw(xml.Header)
381 elmS("posts", false,
382 "user", uid,
383 "dt", tim.Format(IsoDate),
384 "tag", fv("lf_tags"))
385 elmS("post", true,
386 "href", fv("lf_url"),
387 "hash", fv("lf_linkdate"),
388 "description", fv("lf_title"),
389 "extended", fv("lf_description"),
390 "tag", fv("lf_tags"),
391 "time", tim.Format(time.RFC3339),
392 "others", "0")
393 elmE("posts")
395 return
396 case "/v1/posts/recent",
397 "/v1/posts/dates",
398 "/v1/posts/suggest",
399 "/v1/tags/get",
400 "/v1/tags/delete",
401 "/v1/tags/rename",
402 "/v1/user/secret",
403 "/v1/user/api_token",
404 "/v1/notes/list",
405 "/v1/notes/ID":
406 http.Error(w, "Not Implemented", http.StatusNotImplemented)
407 return
409 http.NotFound(w, r)
412 func formValuesFromReader(r io.Reader, name string) (ret url.Values, err error) {
413 root, err := html.Parse(r) // assumes r is UTF8
414 if err != nil {
415 return ret, err
418 for _, form := range scrape.FindAll(root, func(n *html.Node) bool {
419 return atom.Form == n.DataAtom &&
420 (name == scrape.Attr(n, "name") || name == scrape.Attr(n, "id"))
421 }) {
422 ret := url.Values{}
423 for _, inp := range scrape.FindAll(form, func(n *html.Node) bool {
424 return atom.Input == n.DataAtom || atom.Textarea == n.DataAtom
425 }) {
426 n := scrape.Attr(inp, "name")
427 if n == "" {
428 n = scrape.Attr(inp, "id")
431 ty := scrape.Attr(inp, "type")
432 v := scrape.Attr(inp, "value")
433 if atom.Textarea == inp.DataAtom {
434 v = scrape.Text(inp)
435 } else if v == "" && ty == "checkbox" {
436 v = scrape.Attr(inp, "checked")
438 ret.Set(n, v)
440 return ret, err // return on first occurence
442 return ret, err