/v1/get sunshine.
[pin4sha_cgi.git] / pinboard.go
blob645241181dc2d20571633ee0cc0c102eb0d58544
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 "strings"
30 "time"
32 "github.com/yhat/scrape"
33 "golang.org/x/net/html"
34 "golang.org/x/net/html/atom"
35 // "golang.org/x/net/html/charset"
36 "golang.org/x/net/publicsuffix"
39 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
41 // even cooler: https://stackoverflow.com/a/8363629
43 // inspired by // https://coderwall.com/p/cp5fya/measuring-execution-time-in-go
44 func trace(name string) (string, time.Time) { return name, time.Now() }
45 func un(name string, start time.Time) { log.Printf("%s took %s", name, time.Since(start)) }
47 func main() {
48 if true {
49 // lighttpd doesn't seem to like more than one (per-vhost) server.breakagelog
50 log.SetOutput(os.Stderr)
51 } else { // log to custom logfile rather than stderr (may not be reachable on shared hosting)
54 if err := cgi.Serve(http.HandlerFunc(handleMux)); err != nil {
55 log.Fatal(err)
59 // https://pinboard.in/api
61 // All API methods are GET requests, even when good REST habits suggest they should use a different verb.
63 // v1/posts/add
64 // v1/posts/delete
65 // v1/posts/get
66 func handleMux(w http.ResponseWriter, r *http.Request) {
67 defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
68 // w.Header().Set("Server", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
69 // w.Header().Set("X-Powered-By", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
70 // now := time.Now()
72 path_info := os.Getenv("PATH_INFO")
73 base := *r.URL
74 base.Path = base.Path[0:len(base.Path)-len(path_info)] + "/../index.php"
75 // script_name := os.Getenv("SCRIPT_NAME")
76 // urlBase := mustParseURL(string(xmlBaseFromRequestURL(r.URL, os.Getenv("SCRIPT_NAME"))))
77 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
79 switch path_info {
80 case "/v1/info":
81 io.WriteString(w, "r.URL: "+r.URL.String()+"\n")
82 io.WriteString(w, "base: "+base.String()+"\n")
84 return
85 case "/v1/posts/add":
86 uid, pwd, ok := r.BasicAuth()
87 if !ok {
88 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
89 return
92 if "GET" != r.Method {
93 w.Header().Set("Allow", "GET")
94 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
95 return
98 params := r.URL.Query()
99 if 1 != len(params["url"]) {
100 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
101 return
103 p_url := params["url"][0]
105 if 1 != len(params["description"]) {
106 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
107 return
109 p_description := params["description"][0]
111 p_extended := ""
112 if 1 == len(params["extended"]) {
113 p_extended = params["extended"][0]
116 p_tags := ""
117 if 1 == len(params["tags"]) {
118 p_tags = params["tags"][0]
121 v := url.Values{}
122 v.Set("post", p_url)
123 base.RawQuery = v.Encode()
125 // https://stackoverflow.com/a/18414432
126 options := cookiejar.Options{
127 PublicSuffixList: publicsuffix.List,
129 jar, err := cookiejar.New(&options)
130 if err != nil {
131 http.Error(w, err.Error(), http.StatusInternalServerError)
132 return
134 client := http.Client{Jar: jar}
136 resp, err := client.Get(base.String())
137 if err != nil {
138 http.Error(w, err.Error(), http.StatusBadGateway)
139 return
141 formLogi, err := formValuesFromReader(resp.Body, "loginform")
142 resp.Body.Close()
143 if err != nil {
144 http.Error(w, err.Error(), http.StatusInternalServerError)
145 return
148 formLogi.Set("login", uid)
149 formLogi.Set("password", pwd)
150 formLogi.Set("returnurl", r.URL.String())
151 resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
152 if err != nil {
153 http.Error(w, err.Error(), http.StatusBadGateway)
154 return
157 formLink, err := formValuesFromReader(resp.Body, "linkform")
158 resp.Body.Close()
159 if err != nil {
160 http.Error(w, err.Error(), http.StatusBadGateway)
161 return
163 // if we do not have a linkform, auth must have failed.
164 if 0 == len(formLink) {
165 http.Error(w, "Authentication failed", http.StatusForbidden)
166 return
169 // formLink.Set("lf_linkdate", "20190106_172531")
170 // formLink.Set("lf_url", p_url)
171 formLink.Set("lf_title", p_description)
172 formLink.Set("lf_description", p_extended)
173 formLink.Set("lf_tags", p_tags)
175 resp, err = client.PostForm(resp.Request.URL.String(), formLink)
176 resp.Body.Close()
177 if err != nil {
178 http.Error(w, err.Error(), http.StatusBadGateway)
179 return
182 w.Header().Set("Content-Type", "text/xml; charset=utf-8")
183 io.WriteString(w, "<?xml version='1.0' encoding='UTF-8' ?><result code='done' />")
184 return
185 case "/v1/posts/delete":
186 _, _, ok := r.BasicAuth()
187 if !ok {
188 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
189 return
192 if "GET" != r.Method {
193 w.Header().Set("Allow", "GET")
194 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
195 return
198 params := r.URL.Query()
199 if 1 != len(params["url"]) {
200 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
201 return
203 // p_url := params["url"][0]
205 io.WriteString(w, "bhb")
206 return
207 case "/v1/posts/update":
208 _, _, ok := r.BasicAuth()
209 if !ok {
210 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
211 return
214 if "GET" != r.Method {
215 w.Header().Set("Allow", "GET")
216 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
217 return
220 w.Header().Set("Content-Type", "text/xml; charset=utf-8")
221 io.WriteString(w, "<?xml version='1.0' encoding='UTF-8' ?><update time='2011-03-24T19:02:07Z' />")
222 return
223 case "/v1/posts/get":
224 // pretend to add, but don't actually do it, but return the form preset values.
225 uid, pwd, ok := r.BasicAuth()
226 if !ok {
227 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
228 return
231 if "GET" != r.Method {
232 w.Header().Set("Allow", "GET")
233 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
234 return
237 params := r.URL.Query()
238 if 1 != len(params["url"]) {
239 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
240 return
242 p_url := params["url"][0]
245 if 1 != len(params["description"]) {
246 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
247 return
249 p_description := params["description"][0]
251 p_extended := ""
252 if 1 == len(params["extended"]) {
253 p_extended = params["extended"][0]
256 p_tags := ""
257 if 1 == len(params["tags"]) {
258 p_tags = params["tags"][0]
262 v := url.Values{}
263 v.Set("post", p_url)
264 base.RawQuery = v.Encode()
266 // https://stackoverflow.com/a/18414432
267 options := cookiejar.Options{
268 PublicSuffixList: publicsuffix.List,
270 jar, err := cookiejar.New(&options)
271 if err != nil {
272 http.Error(w, err.Error(), http.StatusInternalServerError)
273 return
275 client := http.Client{Jar: jar}
277 resp, err := client.Get(base.String())
278 if err != nil {
279 http.Error(w, err.Error(), http.StatusBadGateway)
280 return
282 formLogi, err := formValuesFromReader(resp.Body, "loginform")
283 resp.Body.Close()
284 if err != nil {
285 http.Error(w, err.Error(), http.StatusInternalServerError)
286 return
289 formLogi.Set("login", uid)
290 formLogi.Set("password", pwd)
291 formLogi.Set("returnurl", r.URL.String())
292 resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
293 if err != nil {
294 http.Error(w, err.Error(), http.StatusBadGateway)
295 return
298 formLink, err := formValuesFromReader(resp.Body, "linkform")
299 resp.Body.Close()
300 if err != nil {
301 http.Error(w, err.Error(), http.StatusBadGateway)
302 return
304 // if we do not have a linkform, auth must have failed.
305 if 0 == len(formLink) {
306 http.Error(w, "Authentication failed", http.StatusForbidden)
307 return
310 t, err := time.Parse("20060102_150405", formLink.Get("lf_linkdate")) // rather ParseInLocation
311 if err != nil {
312 http.Error(w, err.Error(), http.StatusBadGateway)
313 return
316 w.Header().Set("Content-Type", "text/xml; charset=utf-8")
318 rawText := func(s string) { io.WriteString(w, s) }
319 xmlText := func(s string) { xml.EscapeText(w, []byte(s)) }
320 xmlForm := func(s string) { xmlText(formLink.Get(s)) }
322 rawText("<?xml version='1.0' encoding='UTF-8' ?>")
323 rawText("<posts user='")
324 xmlText(uid)
325 rawText("' dt='")
326 xmlText(time.Now().Format("2006-01-02"))
327 rawText("' tag=''>")
328 rawText("<post href='")
329 xmlForm("lf_url")
330 rawText("' hash='")
331 xmlForm("lf_linkdate")
332 rawText("' description='")
333 xmlForm("lf_title")
334 rawText("' extended='")
335 xmlForm("lf_description")
336 rawText("' tag='")
337 xmlForm("lf_tags")
338 rawText("' time='")
339 xmlText(t.Format(time.RFC3339))
340 rawText("' others='")
341 xmlText("0")
342 rawText("' />")
343 rawText("</posts>")
345 return
346 case "/v1/posts/recent":
347 case "/v1/posts/dates":
348 case "/v1/posts/suggest":
349 case "/v1/tags/get":
350 case "/v1/tags/delete":
351 case "/v1/tags/rename":
352 case "/v1/user/secret":
353 case "/v1/user/api_token":
354 case "/v1/notes/list":
355 case "/v1/notes/ID":
356 http.Error(w, "Not Implemented", http.StatusNotImplemented)
357 return
359 http.NotFound(w, r)
362 func formValuesFromReader(r io.Reader, name string) (ret url.Values, err error) {
363 root, err := html.Parse(r) // assumes r is UTF8
364 if err != nil {
365 return ret, err
368 for _, form := range scrape.FindAll(root, func(n *html.Node) bool { return atom.Form == n.DataAtom }) {
369 if name != scrape.Attr(form, "name") && name != scrape.Attr(form, "id") {
370 continue
372 ret := url.Values{}
373 for _, inp := range scrape.FindAll(form, func(n *html.Node) bool { return atom.Input == n.DataAtom || atom.Textarea == n.DataAtom }) {
374 n := scrape.Attr(inp, "name")
375 if n == "" {
376 n = scrape.Attr(inp, "id")
379 ty := scrape.Attr(inp, "type")
380 v := scrape.Attr(inp, "value")
381 if atom.Textarea == inp.DataAtom {
382 v = scrape.Text(inp)
383 } else if v == "" && ty == "checkbox" {
384 v = scrape.Attr(inp, "checked")
386 ret.Set(n, v)
388 return ret, err // return on first occurence
390 return ret, err