api0.go 18 KB

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