api0.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. //
  2. // Copyright (C) 2017-2021 Marcus Rohrmoser, http://purl.mro.name/ShaarliGo
  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.
  13. //
  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/>.
  16. //
  17. package main
  18. import (
  19. "crypto/rand"
  20. "encoding/hex"
  21. "encoding/xml"
  22. "html/template"
  23. "io"
  24. "log"
  25. "net/http"
  26. "net/url"
  27. "path"
  28. "regexp"
  29. "strings"
  30. "time"
  31. "unicode/utf8"
  32. "golang.org/x/crypto/bcrypt"
  33. )
  34. const fmtTimeLfTime = "20060102_150405"
  35. func parseLinkUrl(raw string) *url.URL {
  36. if ret, err := url.Parse(raw); err == nil {
  37. if !ret.IsAbs() {
  38. if ret, err = url.Parse("http://" + raw); err != nil {
  39. return nil
  40. }
  41. }
  42. return ret
  43. }
  44. return nil
  45. }
  46. func (app *Server) handleDoLogin() http.HandlerFunc {
  47. return func(w http.ResponseWriter, r *http.Request) {
  48. now := time.Now()
  49. switch r.Method {
  50. // and https://code.mro.name/mro/ShaarliOS/src/1d124e012933d1209d64071a90237dc5ec6372fc/ios/ShaarliOS/API/ShaarliCmd.m#L386
  51. case http.MethodGet:
  52. returnurl := r.Referer()
  53. if ru := r.URL.Query()["returnurl"]; ru != nil && 1 == len(ru) && "" != ru[0] {
  54. returnurl = ru[0]
  55. }
  56. byt, _ := tplLoginHtmlBytes()
  57. if tmpl, err := template.New("login").Parse(string(byt)); err == nil {
  58. w.Header().Set("Content-Type", "text/xml; charset=utf-8")
  59. io.WriteString(w, xml.Header)
  60. io.WriteString(w, `<?xml-stylesheet type='text/xsl' href='./themes/current/do-login.xslt'?>
  61. <!--
  62. must be compatible with https://code.mro.name/mro/Shaarli-API-test/src/master/tests/test-post.sh
  63. https://code.mro.name/mro/ShaarliOS/src/1d124e012933d1209d64071a90237dc5ec6372fc/ios/ShaarliOS/API/ShaarliCmd.m#L386
  64. -->
  65. `)
  66. if err := tmpl.Execute(w, map[string]string{
  67. "title": app.cfg.Title,
  68. "token": "ff13e7eaf9541ca2ba30fd44e864c3ff014d2bc9",
  69. "returnurl": returnurl,
  70. }); err != nil {
  71. http.Error(w, "Couldn't send login form: "+err.Error(), http.StatusInternalServerError)
  72. }
  73. }
  74. case http.MethodPost:
  75. val := func(key string) string { return strings.TrimSpace(r.FormValue(key)) }
  76. // todo: verify token
  77. uid := val("login")
  78. pwd := val("password")
  79. returnurl := val("returnurl")
  80. // compute anyway (a bit more time constantness)
  81. err := bcrypt.CompareHashAndPassword([]byte(app.cfg.PwdBcrypt), []byte(pwd))
  82. if uid != app.cfg.Uid || err == bcrypt.ErrMismatchedHashAndPassword {
  83. squealFailure(r, now, "Unauthorised.")
  84. // http.Error(w, "<script>alert(\"Wrong login/password.\");document.location='?do=login&returnurl='"+url.QueryEscape(returnurl)+"';</script>", http.StatusUnauthorized)
  85. w.WriteHeader(http.StatusUnauthorized)
  86. loc := cgiName + "?do=login&returnurl=" + url.QueryEscape(returnurl)
  87. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  88. // Can't do a CSP header as with self.close – the returnurl isn't const.
  89. // So maybe this won't ever trigger. Add a clickable link as a fallback.
  90. io.WriteString(w, "<script>alert(\"Wrong login/password.\");document.location='"+loc+"';</script><p>Wrong login/password. <a href='"+loc+"'>Try again</a></p>")
  91. return
  92. }
  93. if err == nil {
  94. err = app.startSession(w, r, now)
  95. }
  96. if err == nil {
  97. if "" == returnurl { // TODO restrict to local urls within app scope
  98. returnurl = path.Join(uriPub, uriPosts) + "/"
  99. }
  100. http.Redirect(w, r, returnurl, http.StatusFound)
  101. return
  102. }
  103. http.Error(w, "Fishy post: "+err.Error(), http.StatusInternalServerError)
  104. default:
  105. squealFailure(r, now, "MethodNotAllowed "+r.Method)
  106. http.Error(w, "MethodNotAllowed", http.StatusMethodNotAllowed)
  107. }
  108. // NSString *xpath = [NSString stringWithFormat:@"/html/body//form[@name='%1$@']//input[(@type='text' or @type='password' or @type='hidden' or @type='checkbox') and @name] | /html/body//form[@name='%1$@']//textarea[@name]
  109. // 'POST' validate, respond error (and squeal) or set session and redirect
  110. }
  111. }
  112. func (app *Server) handleDoLogout() http.HandlerFunc {
  113. return func(w http.ResponseWriter, r *http.Request) {
  114. if err := app.stopSession(w, r); err != nil {
  115. http.Error(w, "Couldn't end session: "+err.Error(), http.StatusInternalServerError)
  116. } else {
  117. http.Redirect(w, r, path.Join(uriPub, uriPosts)+"/", http.StatusFound)
  118. }
  119. }
  120. }
  121. func sanitiseURLString(raw string, lst []RegexpReplaceAllString) string {
  122. for idx, row := range lst {
  123. if rex, err := regexp.Compile(row.Regexp); err != nil {
  124. log.Printf("Invalid regular expression #%d '%s': %s", idx, row.Regexp, err)
  125. } else {
  126. raw = rex.ReplaceAllString(raw, row.ReplaceAllString)
  127. }
  128. }
  129. return raw
  130. }
  131. func urlFromPostParam(post string) *url.URL {
  132. if url, err := url.Parse(post); err == nil && url != nil && url.IsAbs() && "" != url.Hostname() {
  133. return url
  134. } else {
  135. if nil != url && !url.IsAbs() {
  136. if !strings.ContainsRune(post, '.') {
  137. return nil
  138. }
  139. post = strings.Join([]string{"http://", post}, "")
  140. if url, err := url.Parse(post); err == nil && url != nil && url.IsAbs() && "" != url.Hostname() {
  141. return url
  142. }
  143. }
  144. return nil
  145. }
  146. }
  147. func termsVisitor(entries ...*Entry) func(func(string)) {
  148. return func(callback func(string)) {
  149. for _, ee := range entries {
  150. for _, ca := range ee.Categories {
  151. callback(ca.Term)
  152. }
  153. }
  154. }
  155. }
  156. /* Store identifier of edited entry in cookie.
  157. */
  158. func (app *Server) handleDoPost(posse func(Entry)) http.HandlerFunc {
  159. return func(w http.ResponseWriter, r *http.Request) {
  160. now := time.Now()
  161. switch r.Method {
  162. case http.MethodGet:
  163. // 'GET': send a form to the client
  164. // must be compatible with https://code.mro.name/mro/Shaarli-API-Test/...
  165. // and https://code.mro.name/mro/ShaarliOS/src/1d124e012933d1209d64071a90237dc5ec6372fc/ios/ShaarliOS/API/ShaarliCmd.m#L386
  166. if !app.IsLoggedIn(now) {
  167. http.Redirect(w, r, cgiName+"?do=login&returnurl="+url.QueryEscape(r.URL.String()), http.StatusFound)
  168. return
  169. }
  170. params := r.URL.Query()
  171. if 1 != len(params["post"]) {
  172. http.Error(w, "StatusBadRequest", http.StatusBadRequest)
  173. return
  174. }
  175. feed, _ := LoadFeed()
  176. post := sanitiseURLString(params["post"][0], app.cfg.UrlCleaner)
  177. feed.XmlBase = Iri(app.url.String())
  178. _, ent := feed.findEntryByIdSelfOrUrl(post)
  179. if nil == ent {
  180. // nothing found, so we need a new (dangling, unsaved) entry:
  181. if url := urlFromPostParam(post); url == nil {
  182. // post parameter doesn't look like an url, so we treat it as a note.
  183. ent = &Entry{}
  184. ent.Title = HumanText{Body: post}
  185. } else {
  186. // post parameter looks like an url
  187. if 1 == len(params["scrape"]) && "no" == params["scrape"][0] {
  188. ent = &Entry{}
  189. } else {
  190. // so we try to GET it
  191. ee, err := entryFromURL(url, time.Second*3/2)
  192. if nil != err {
  193. ee.Title.Body = err.Error()
  194. }
  195. ent = &ee
  196. }
  197. if nil == ent.Content || "" == ent.Content.Body {
  198. ent.Content = ent.Summary
  199. }
  200. ent.Links = []Link{{Href: url.String()}}
  201. }
  202. ent.Updated = iso8601(now)
  203. const SetPublishedToNowInitially = true
  204. if SetPublishedToNowInitially || ent.Published.IsZero() {
  205. ent.Published = ent.Updated
  206. }
  207. // do not append to feed yet, keep dangling
  208. } else {
  209. log.Printf("storing Id in cookie: %v", ent.Id)
  210. app.ses.Values["identifier"] = ent.Id
  211. }
  212. app.KeepAlive(w, r, now)
  213. if 1 == len(params["tags"]) && "" != params["tags"][0] {
  214. tags := regexp.MustCompile("[ ,]+").Split(params["tags"][0], -1)
  215. a := make([]Category, 0, len(tags))
  216. for _, tag := range tags {
  217. a = append(a, Category{Term: tag})
  218. }
  219. ent.Categories = a
  220. }
  221. if 1 == len(params["title"]) && "" != params["title"][0] {
  222. ent.Title = HumanText{Body: params["title"][0]}
  223. }
  224. if 1 == len(params["description"]) && "" != params["description"][0] {
  225. ent.Content = &HumanText{Body: params["description"][0]}
  226. }
  227. if 1 == len(params["source"]) {
  228. // data["lf_source"] = params["source"][0]
  229. }
  230. byt, _ := tplLinkformHtmlBytes() // todo: err => 500
  231. if tmpl, err := template.New("linkform").Parse(string(byt)); err == nil {
  232. w.Header().Set("Content-Type", "text/xml; charset=utf-8")
  233. io.WriteString(w, xml.Header)
  234. io.WriteString(w, `<?xml-stylesheet type='text/xsl' href='./themes/current/do-post.xslt'?>
  235. <!--
  236. must be compatible with https://code.mro.name/mro/Shaarli-API-test/src/master/tests/test-post.sh
  237. https://code.mro.name/mro/ShaarliOS/src/1d124e012933d1209d64071a90237dc5ec6372fc/ios/ShaarliOS/API/ShaarliCmd.m#L386
  238. -->
  239. `)
  240. data := ent.api0LinkFormMap()
  241. data["title"] = feed.Title.Body
  242. data["categories"] = feed.Categories
  243. bTok := make([]byte, 20) // keep in local session or encrypted cookie
  244. io.ReadFull(rand.Reader, bTok)
  245. data["token"] = hex.EncodeToString(bTok)
  246. data["returnurl"] = ""
  247. data["xml_base"] = feed.XmlBase
  248. if err := tmpl.Execute(w, data); err != nil {
  249. http.Error(w, "Coudln't send linkform: "+err.Error(), http.StatusInternalServerError)
  250. }
  251. }
  252. case http.MethodPost:
  253. val := func(key string) string { return strings.TrimSpace(r.FormValue(key)) }
  254. // 'POST' validate, respond error (and squeal) or post and redirect
  255. if !app.IsLoggedIn(now) {
  256. squealFailure(r, now, "Unauthorised")
  257. http.Error(w, "Unauthorized", http.StatusUnauthorized)
  258. return
  259. }
  260. identifier, ok := app.ses.Values["identifier"].(Id)
  261. if ok {
  262. delete(app.ses.Values, "identifier")
  263. }
  264. log.Printf("pulled Id from cookie: %v", identifier)
  265. app.KeepAlive(w, r, now)
  266. location := path.Join(uriPub, uriPosts) + "/"
  267. // https://github.com/sebsauvage/Shaarli/blob/master/index.php#L1479
  268. if "" != val("save_edit") {
  269. if lf_linkdate, err := time.ParseInLocation(fmtTimeLfTime, val("lf_linkdate"), app.tz); err != nil {
  270. squealFailure(r, now, "BadRequest: "+err.Error())
  271. http.Error(w, "Looks like a forged request: "+err.Error(), http.StatusBadRequest)
  272. return
  273. } else {
  274. log.Println("todo: check token ", val("token"))
  275. if returnurl, err := url.Parse(val("returnurl")); err != nil {
  276. log.Println("Error parsing returnurl: ", err.Error())
  277. http.Error(w, "couldn't parse returnurl: "+err.Error(), http.StatusInternalServerError)
  278. return
  279. } else {
  280. log.Println("todo: use returnurl ", returnurl)
  281. // make persistent
  282. feed, _ := LoadFeed()
  283. feed.XmlBase = Iri(app.url.String())
  284. lf_url := val("lf_url")
  285. _, ent := feed.findEntryById(identifier)
  286. if nil == ent {
  287. ent = feed.newEntry(lf_linkdate)
  288. if _, err := feed.Append(ent); err != nil {
  289. http.Error(w, "couldn't add entry: "+err.Error(), http.StatusInternalServerError)
  290. return
  291. }
  292. }
  293. ent0 := *ent
  294. // prepare redirect
  295. location = strings.Join([]string{location, string(ent.Id)}, "?#")
  296. // human := func(key string) HumanText { return HumanText{Body: val(key), Type: "text"} }
  297. // humanP := func(key string) *HumanText { t := human(key); return &t }
  298. ent.Updated = iso8601(now)
  299. url := mustParseURL(lf_url)
  300. if url.IsAbs() && "" != url.Host {
  301. ent.Links = []Link{{Href: lf_url}}
  302. } else {
  303. ent.Links = []Link{}
  304. }
  305. ds, ex, tags := tagsNormalise(
  306. val("lf_title"),
  307. val("lf_description"),
  308. tagsVisitor(strings.Split(val("lf_tags"), " ")...),
  309. termsVisitor(feed.Entries...),
  310. )
  311. ent.Title = HumanText{Body: ds, Type: "text"}
  312. ent.Content = &HumanText{Body: ex, Type: "text"}
  313. {
  314. a := make([]Category, 0, len(tags))
  315. for _, tag := range tags {
  316. a = append(a, Category{Term: tag})
  317. }
  318. ent.Categories = a
  319. }
  320. if img := val("lf_image"); "" != img {
  321. ent.MediaThumbnail = &MediaThumbnail{Url: Iri(img)}
  322. }
  323. if err := ent.Validate(); err != nil {
  324. http.Error(w, "couldn't add entry: "+err.Error(), http.StatusInternalServerError)
  325. return
  326. }
  327. if err := app.SaveFeed(feed); err != nil {
  328. http.Error(w, "couldn't store feed data: "+err.Error(), http.StatusInternalServerError)
  329. return
  330. }
  331. // todo: waiting group? fire and forget go function?
  332. // we should, however, lock re-entrancy
  333. posse(*ent)
  334. // refresh feeds
  335. if err := app.PublishFeedsForModifiedEntries(feed, []*Entry{ent, &ent0}); err != nil {
  336. log.Println("couldn't write feeds: ", err.Error())
  337. http.Error(w, "couldn't write feeds: "+err.Error(), http.StatusInternalServerError)
  338. return
  339. }
  340. }
  341. }
  342. } else if "" != val("cancel_edit") {
  343. } else if "" != val("delete_edit") {
  344. token := val("token")
  345. log.Println("todo: check token ", token)
  346. // make persistent
  347. feed, _ := LoadFeed()
  348. if ent := feed.deleteEntryById(identifier); nil != ent {
  349. if err := app.SaveFeed(feed); err != nil {
  350. http.Error(w, "couldn't store feed data: "+err.Error(), http.StatusInternalServerError)
  351. return
  352. }
  353. // todo: POSSE
  354. // refresh feeds
  355. feed.XmlBase = Iri(app.url.String())
  356. if err := app.PublishFeedsForModifiedEntries(feed, []*Entry{ent}); err != nil {
  357. log.Println("couldn't write feeds: ", err.Error())
  358. http.Error(w, "couldn't write feeds: "+err.Error(), http.StatusInternalServerError)
  359. return
  360. }
  361. } else {
  362. squealFailure(r, now, "Not Found")
  363. log.Println("entry not found: ", identifier)
  364. http.Error(w, "Not Found", http.StatusNotFound)
  365. return
  366. }
  367. } else {
  368. squealFailure(r, now, "BadRequest")
  369. http.Error(w, "BadRequest", http.StatusBadRequest)
  370. return
  371. }
  372. if "bookmarklet" == val("source") {
  373. w.WriteHeader(http.StatusOK)
  374. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  375. // CSP script-src 'sha256-hGqewLn4csF93PEX/0TCk2jdnAytXBZFxFBzKt7wcgo='
  376. // echo -n "self.close(); // close bookmarklet popup" | openssl dgst -sha256 -binary | base64
  377. io.WriteString(w, "<script>self.close(); // close bookmarklet popup</script>")
  378. } else {
  379. http.Redirect(w, r, location, http.StatusFound)
  380. }
  381. return
  382. default:
  383. squealFailure(r, now, "MethodNotAllowed: "+r.Method)
  384. http.Error(w, "MethodNotAllowed", http.StatusMethodNotAllowed)
  385. return
  386. }
  387. }
  388. }
  389. func (app *Server) handleDoCheckLoginAfterTheFact() http.HandlerFunc {
  390. return func(w http.ResponseWriter, r *http.Request) {
  391. now := time.Now()
  392. switch r.Method {
  393. case http.MethodGet:
  394. if !app.IsLoggedIn(now) {
  395. http.Redirect(w, r, cgiName+"?do=login&returnurl="+url.QueryEscape(r.URL.String()), http.StatusFound)
  396. return
  397. }
  398. app.KeepAlive(w, r, now)
  399. byt, _ := tplChangepasswordformHtmlBytes()
  400. if tmpl, err := template.New("changepasswordform").Parse(string(byt)); err == nil {
  401. w.Header().Set("Content-Type", "text/xml; charset=utf-8")
  402. io.WriteString(w, xml.Header)
  403. io.WriteString(w, `<?xml-stylesheet type='text/xsl' href='./themes/current/do-changepassword.xslt'?>
  404. <!--
  405. must be compatible with https://code.mro.name/mro/Shaarli-API-test/src/master/tests/test-post.sh
  406. https://code.mro.name/mro/ShaarliOS/src/1d124e012933d1209d64071a90237dc5ec6372fc/ios/ShaarliOS/API/ShaarliCmd.m#L386
  407. -->
  408. `)
  409. data := make(map[string]string)
  410. data["title"] = app.cfg.Title
  411. bTok := make([]byte, 20) // keep in local session or encrypted cookie
  412. io.ReadFull(rand.Reader, bTok)
  413. data["token"] = hex.EncodeToString(bTok)
  414. data["returnurl"] = ""
  415. if err := tmpl.Execute(w, data); err != nil {
  416. http.Error(w, "Coudln't send changepasswordform: "+err.Error(), http.StatusInternalServerError)
  417. }
  418. }
  419. }
  420. }
  421. }
  422. // Aggregate all tags from #title, #description and <category and remove the first two groups from the set.
  423. func (entry Entry) api0LinkFormMap() map[string]interface{} {
  424. body := func(t *HumanText) string {
  425. if t == nil {
  426. return ""
  427. }
  428. return t.Body
  429. }
  430. data := map[string]interface{}{
  431. "lf_linkdate": entry.Published.Format(fmtTimeLfTime),
  432. }
  433. {
  434. ti, de, ta := tagsNormalise(
  435. body(&entry.Title),
  436. body(entry.Content),
  437. termsVisitor(&entry),
  438. termsVisitor(&entry), // rather all the feed's tags, but as we don't have them it's ok, too.
  439. )
  440. data["lf_title"] = ti
  441. data["lf_description"] = de
  442. data["lf_tags"] = strings.Join(ta, " ")
  443. }
  444. for _, li := range entry.Links {
  445. if "" == li.Rel {
  446. data["lf_url"] = li.Href
  447. break
  448. }
  449. }
  450. if "" == data["lf_url"] && "" != entry.Id {
  451. // todo: also if it's not a note
  452. data["lf_url"] = entry.Id
  453. }
  454. if nil != entry.MediaThumbnail && len(entry.MediaThumbnail.Url) > 0 {
  455. data["lf_image"] = entry.MediaThumbnail.Url
  456. }
  457. for key, value := range data {
  458. if s, ok := value.(string); ok && !utf8.ValidString(s) {
  459. data[key] = "Invalid UTF8"
  460. }
  461. }
  462. return data
  463. }
  464. func (feed *Feed) findEntryByIdSelfOrUrl(id_self_or_link string) (int, *Entry) {
  465. defer un(trace(strings.Join([]string{"Feed.findEntryByIdSelfOrUrl('", id_self_or_link, "')"}, "")))
  466. if "" != id_self_or_link {
  467. if parts := strings.SplitN(id_self_or_link, "/", 4); 4 == len(parts) && "" == parts[3] && uriPub == parts[0] && uriPosts == parts[1] {
  468. // looks like an internal id, so treat it as such.
  469. id_self_or_link = parts[2]
  470. }
  471. doesMatch := func(entry *Entry) bool {
  472. if id_self_or_link == string(entry.Id) {
  473. return true
  474. }
  475. for _, l := range entry.Links {
  476. if ("" == l.Rel || "self" == l.Rel) && (id_self_or_link == l.Href /* todo: url equal */) {
  477. return true
  478. }
  479. }
  480. return false
  481. }
  482. return feed.findEntry(doesMatch)
  483. }
  484. return feed.findEntry(nil)
  485. }