/v1/get – a shot into the dark.
[pin4sha_cgi.git] / pinboard.go
blobd46d6cf7a0d859d756899bbeb05710de44db545a
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 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
94 return
97 params := r.URL.Query()
98 if 1 != len(params["url"]) {
99 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
100 return
102 p_url := params["url"][0]
104 if 1 != len(params["description"]) {
105 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
106 return
108 p_description := params["description"][0]
110 p_extended := ""
111 if 1 == len(params["extended"]) {
112 p_extended = params["extended"][0]
115 p_tags := ""
116 if 1 == len(params["tags"]) {
117 p_tags = params["tags"][0]
120 v := url.Values{}
121 v.Set("post", p_url)
122 base.RawQuery = v.Encode()
124 // https://stackoverflow.com/a/18414432
125 options := cookiejar.Options{
126 PublicSuffixList: publicsuffix.List,
128 jar, err := cookiejar.New(&options)
129 if err != nil {
130 http.Error(w, err.Error(), http.StatusInternalServerError)
131 return
133 client := http.Client{Jar: jar}
135 resp, err := client.Get(base.String())
136 if err != nil {
137 http.Error(w, err.Error(), http.StatusBadGateway)
138 return
140 formLogi, err := formValuesFromReader(resp.Body, "loginform")
141 resp.Body.Close()
142 if err != nil {
143 http.Error(w, err.Error(), http.StatusInternalServerError)
144 return
147 formLogi.Set("login", uid)
148 formLogi.Set("password", pwd)
149 formLogi.Set("returnurl", r.URL.String())
150 resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
151 if err != nil {
152 http.Error(w, err.Error(), http.StatusBadGateway)
153 return
156 formLink, err := formValuesFromReader(resp.Body, "linkform")
157 resp.Body.Close()
158 if err != nil {
159 http.Error(w, err.Error(), http.StatusBadGateway)
160 return
162 // if we do not have a linkform, auth must have failed.
163 if 0 == len(formLink) {
164 http.Error(w, "Authentication failed", http.StatusForbidden)
165 return
168 // formLink.Set("lf_linkdate", "20190106_172531")
169 // formLink.Set("lf_url", p_url)
170 formLink.Set("lf_title", p_description)
171 formLink.Set("lf_description", p_extended)
172 formLink.Set("lf_tags", p_tags)
174 resp, err = client.PostForm(resp.Request.URL.String(), formLink)
175 resp.Body.Close()
176 if err != nil {
177 http.Error(w, err.Error(), http.StatusBadGateway)
178 return
181 w.Header().Set("Content-Type", "text/xml; charset=utf-8")
182 io.WriteString(w, "<?xml version='1.0' encoding='UTF-8' ?><result code='done' />")
183 return
184 case "/v1/posts/delete":
185 _, _, ok := r.BasicAuth()
186 if !ok {
187 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
188 return
191 if "GET" != r.Method {
192 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
193 return
196 params := r.URL.Query()
197 if 1 != len(params["url"]) {
198 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
199 return
201 // p_url := params["url"][0]
203 io.WriteString(w, "bhb")
204 return
205 case "/v1/posts/update":
206 _, _, ok := r.BasicAuth()
207 if !ok {
208 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
209 return
212 if "GET" != r.Method {
213 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
214 return
217 w.Header().Set("Content-Type", "text/xml; charset=utf-8")
218 io.WriteString(w, "<?xml version='1.0' encoding='UTF-8' ?><update time='2011-03-24T19:02:07Z' />")
219 return
220 case "/v1/posts/get":
221 // pretend to add, but don't actually do it, but return the form preset values.
222 uid, pwd, ok := r.BasicAuth()
223 if !ok {
224 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
225 return
228 if "GET" != r.Method {
229 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
230 return
233 params := r.URL.Query()
234 if 1 != len(params["url"]) {
235 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
236 return
238 p_url := params["url"][0]
241 if 1 != len(params["description"]) {
242 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
243 return
245 p_description := params["description"][0]
247 p_extended := ""
248 if 1 == len(params["extended"]) {
249 p_extended = params["extended"][0]
252 p_tags := ""
253 if 1 == len(params["tags"]) {
254 p_tags = params["tags"][0]
258 v := url.Values{}
259 v.Set("post", p_url)
260 base.RawQuery = v.Encode()
262 // https://stackoverflow.com/a/18414432
263 options := cookiejar.Options{
264 PublicSuffixList: publicsuffix.List,
266 jar, err := cookiejar.New(&options)
267 if err != nil {
268 http.Error(w, err.Error(), http.StatusInternalServerError)
269 return
271 client := http.Client{Jar: jar}
273 resp, err := client.Get(base.String())
274 if err != nil {
275 http.Error(w, err.Error(), http.StatusBadGateway)
276 return
278 formLogi, err := formValuesFromReader(resp.Body, "loginform")
279 resp.Body.Close()
280 if err != nil {
281 http.Error(w, err.Error(), http.StatusInternalServerError)
282 return
285 formLogi.Set("login", uid)
286 formLogi.Set("password", pwd)
287 formLogi.Set("returnurl", r.URL.String())
288 resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
289 if err != nil {
290 http.Error(w, err.Error(), http.StatusBadGateway)
291 return
294 formLink, err := formValuesFromReader(resp.Body, "linkform")
295 resp.Body.Close()
296 if err != nil {
297 http.Error(w, err.Error(), http.StatusBadGateway)
298 return
300 // if we do not have a linkform, auth must have failed.
301 if 0 == len(formLink) {
302 http.Error(w, "Authentication failed", http.StatusForbidden)
303 return
306 t, err := time.Parse("2006-01-02_150405", formLink.Get("lf_linkdate")) // rather ParseInLocation
307 if err != nil {
308 http.Error(w, err.Error(), http.StatusBadGateway)
309 return
312 w.Header().Set("Content-Type", "text/xml; charset=utf-8")
314 rawText := func(s string) { io.WriteString(w, s) }
315 xmlText := func(s string) { xml.EscapeText(w, []byte(s)) }
316 xmlForm := func(s string) { xmlText(formLink.Get(s)) }
318 rawText("<?xml version='1.0' encoding='UTF-8' ?>")
319 rawText("<posts user='")
320 xmlText(uid)
321 rawText("' dt='")
322 xmlText(time.Now().Format("2006-01-02"))
323 rawText("' tag=''>")
324 rawText("<post href='")
325 xmlForm("lf_url")
326 rawText("' hash='")
327 xmlText("...id...")
328 rawText("' description='")
329 xmlForm("lf_title")
330 rawText("' extended='")
331 xmlForm("lf_description")
332 rawText("' tag='")
333 xmlForm("lf_tags")
334 rawText("' time='")
335 xmlText(t.Format(time.RFC3339))
336 rawText("' others='")
337 xmlText("0")
338 rawText("' />")
339 rawText("</posts>")
341 return
342 case "/v1/posts/recent":
343 case "/v1/posts/dates":
344 case "/v1/posts/suggest":
345 case "/v1/tags/get":
346 case "/v1/tags/delete":
347 case "/v1/tags/rename":
348 case "/v1/user/secret":
349 case "/v1/user/api_token":
350 case "/v1/notes/list":
351 case "/v1/notes/ID":
352 http.Error(w, "Not Implemented", http.StatusNotImplemented)
353 return
355 http.NotFound(w, r)
358 func formValuesFromReader(r io.Reader, name string) (ret url.Values, err error) {
359 root, err := html.Parse(r) // assumes r is UTF8
360 if err != nil {
361 return ret, err
364 for _, form := range scrape.FindAll(root, func(n *html.Node) bool { return atom.Form == n.DataAtom }) {
365 if name != scrape.Attr(form, "name") && name != scrape.Attr(form, "id") {
366 continue
368 ret := url.Values{}
369 for _, inp := range scrape.FindAll(form, func(n *html.Node) bool { return atom.Input == n.DataAtom || atom.Textarea == n.DataAtom }) {
370 n := scrape.Attr(inp, "name")
371 if n == "" {
372 n = scrape.Attr(inp, "id")
375 ty := scrape.Attr(inp, "type")
376 v := scrape.Attr(inp, "value")
377 if atom.Textarea == inp.DataAtom {
378 v = scrape.Text(inp)
379 } else if v == "" && ty == "checkbox" {
380 v = scrape.Attr(inp, "checked")
382 ret.Set(n, v)
384 return ret, err // return on first occurence
386 return ret, err