api0.go 17 KB

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