api0.go 17 KB

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