ShaarliGo.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  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. // Files & Directories
  18. //
  19. // .htaccess
  20. // shaarligo.cgi
  21. // app/.htaccess
  22. // app/config.yaml
  23. // app/posts.gob.gz
  24. // app/posts.xml.gz
  25. // app/var/bans.yaml
  26. // app/var/error.log
  27. // app/var/stage/
  28. // app/var/old/
  29. // themes/current/
  30. // o/p/
  31. package main
  32. import (
  33. "encoding/base64"
  34. "encoding/gob"
  35. "encoding/xml"
  36. "fmt"
  37. "io"
  38. "log"
  39. "net/http"
  40. "net/http/cgi"
  41. "net/url"
  42. "os"
  43. "path"
  44. "path/filepath"
  45. "strconv"
  46. "strings"
  47. "sync"
  48. "time"
  49. "github.com/gorilla/sessions"
  50. )
  51. const toSession = 30 * time.Minute
  52. const myselfNamespace = "http://purl.mro.name/ShaarliGo/"
  53. 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
  54. var fileFeedStorage string
  55. func init() {
  56. fileFeedStorage = filepath.Join(dirApp, "var", uriPub+".atom")
  57. gob.Register(Id("")) // http://www.gorillatoolkit.org/pkg/sessions
  58. }
  59. // even cooler: https://stackoverflow.com/a/8363629
  60. //
  61. // inspired by // https://coderwall.com/p/cp5fya/measuring-execution-time-in-go
  62. func trace(name string) (string, time.Time) { return name, time.Now() }
  63. func un(name string, start time.Time) { log.Printf("%s took %s", name, time.Since(start)) }
  64. func LoadFeed() (Feed, error) {
  65. defer un(trace("LoadFeed"))
  66. if feed, err := FeedFromFileName(fileFeedStorage); err != nil {
  67. return feed, err
  68. } else {
  69. for _, ent := range feed.Entries {
  70. if 6 == len(ent.Id) {
  71. if id, err := base64ToBase24x7(string(ent.Id)); err != nil {
  72. log.Printf("Error converting id \"%s\": %s\n", ent.Id, err)
  73. } else {
  74. log.Printf("shaarli_go_path_0 + \"?(%[1]s|\\?)%[2]s/?$\" => \"%[1]s%[3]s/\",\n", uriPubPosts, ent.Id, id)
  75. ent.Id = Id(id)
  76. }
  77. }
  78. }
  79. return feed, nil
  80. }
  81. }
  82. // are we running cli
  83. func runCli() bool {
  84. if 0 != len(os.Getenv("REQUEST_METHOD")) {
  85. return false
  86. }
  87. fmt.Printf("%sv%s+%s#:\n", myselfNamespace, version, GitSHA1)
  88. cfg, err := LoadConfig()
  89. if err != nil {
  90. panic(err)
  91. }
  92. fmt.Printf(" timezone: %s\n", cfg.TimeZone)
  93. feed, err := LoadFeed()
  94. if os.IsNotExist(err) {
  95. cwd, _ := os.Getwd()
  96. fmt.Fprintf(os.Stderr, "%s: cannot access %s: No such file or directory\n", filepath.Base(os.Args[0]), filepath.Join(cwd, fileFeedStorage))
  97. os.Exit(1)
  98. return true
  99. }
  100. if err != nil {
  101. panic(err)
  102. }
  103. fmt.Printf(" posts: %d\n", len(feed.Entries))
  104. //fmt.Printf(" tags: %d\n", len(feed.Categories))
  105. if 0 < len(feed.Entries) {
  106. fmt.Printf(" first: %v\n", feed.Entries[len(feed.Entries)-1].Published.Format(time.RFC3339))
  107. fmt.Printf(" last: %v\n", feed.Entries[0].Published.Format(time.RFC3339))
  108. }
  109. return true
  110. }
  111. // evtl. as a server, too: http://www.dav-muz.net/blog/2013/09/how-to-use-go-and-fastcgi/
  112. func main() {
  113. if runCli() {
  114. return
  115. }
  116. if false {
  117. // lighttpd doesn't seem to like more than one (per-vhost) server.breakagelog
  118. log.SetOutput(os.Stderr)
  119. } else { // log to custom logfile rather than stderr (may not be reachable on shared hosting)
  120. dst := filepath.Join(dirApp, "var", "log", "error.log")
  121. if err := os.MkdirAll(filepath.Dir(dst), 0770); err != nil {
  122. log.Fatal("Couldn't create app/var/log dir: " + err.Error())
  123. return
  124. }
  125. if fileLog, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0660); err != nil {
  126. log.Fatal("Couldn't open logfile: " + err.Error())
  127. return
  128. } else {
  129. defer fileLog.Close()
  130. log.SetOutput(fileLog)
  131. }
  132. }
  133. wg := &sync.WaitGroup{}
  134. // - check non-write perm of program?
  135. // - check non-http read perm on ./app
  136. if err := cgi.Serve(http.HandlerFunc(handleMux(wg))); err != nil {
  137. log.Fatal(err)
  138. }
  139. wg.Wait()
  140. }
  141. type Server struct {
  142. cfg Config
  143. ses *sessions.Session
  144. tz *time.Location
  145. url url.URL
  146. cgi url.URL
  147. }
  148. func (app *Server) startSession(w http.ResponseWriter, r *http.Request, now time.Time) error {
  149. app.ses.Values["timeout"] = now.Add(toSession).Unix()
  150. return app.ses.Save(r, w)
  151. }
  152. func (app *Server) stopSession(w http.ResponseWriter, r *http.Request) error {
  153. delete(app.ses.Values, "timeout")
  154. return app.ses.Save(r, w)
  155. }
  156. func (app *Server) KeepAlive(w http.ResponseWriter, r *http.Request, now time.Time) error {
  157. if app.IsLoggedIn(now) {
  158. return app.startSession(w, r, now)
  159. }
  160. return nil
  161. }
  162. func (app Server) IsLoggedIn(now time.Time) bool {
  163. // https://gowebexamples.com/sessions/
  164. // or https://stackoverflow.com/questions/28616830/gorilla-sessions-how-to-automatically-update-cookie-expiration-on-request
  165. timeout, ok := app.ses.Values["timeout"].(int64)
  166. return ok && now.Before(time.Unix(timeout, 0))
  167. }
  168. // Internal storage, not publishing.
  169. func (app Server) SaveFeed(feed Feed) error {
  170. defer un(trace("Server.SaveFeed"))
  171. feed.Id = ""
  172. feed.XmlBase = ""
  173. feed.Generator = nil
  174. feed.Updated = iso8601{}
  175. feed.Categories = nil
  176. return feed.SaveToFile(fileFeedStorage)
  177. }
  178. func (app Server) Posse(en Entry) {
  179. defer un(trace("Server.Posse"))
  180. to := 4 * time.Second
  181. back := func(prefix string, base url.URL, id Id) string {
  182. if "" == prefix {
  183. prefix = "¹ " + base.String() + uriPubPosts
  184. }
  185. return prefix + string(id)
  186. }
  187. for _, po := range app.cfg.Posse {
  188. switch pi := po.(type) {
  189. case Pinboard:
  190. if ep, err := url.Parse(pi.Endpoint); err != nil {
  191. log.Printf("- posse %s error %s\n", pi.Endpoint, err)
  192. } else {
  193. if ur, err := pinboardPostsAdd(*ep, en, back(pi.Prefix, app.url, en.Id)); err != nil {
  194. log.Printf("- posse %s error %s\n", ep, err)
  195. } else {
  196. if _, err := HttpGetBody(&ur, to); err != nil {
  197. log.Printf("- posse %s error %s\n", ur.String(), err)
  198. } else {
  199. // TODO: check response
  200. log.Printf("- posse %s\n", ur.String())
  201. }
  202. }
  203. }
  204. case Mastodon:
  205. max := func(x, y int) int {
  206. if x < y {
  207. return y
  208. }
  209. return x
  210. }
  211. if ep, err := url.Parse(pi.Endpoint); err != nil {
  212. log.Printf("- posse %s error %s\n", pi.Endpoint, err)
  213. } else {
  214. limit, _ := strconv.Atoi(pi.Limit)
  215. if limit <= 0 {
  216. limit = 500 // default
  217. }
  218. limit = max(100, limit) // mimimum
  219. if err := mastodonStatusPost(*ep, pi.Token, limit, en, back(pi.Prefix, app.url, en.Id)); err != nil {
  220. log.Printf("- posse %s error %s\n", ep, err)
  221. }
  222. }
  223. default:
  224. log.Printf("I don't know about type '%T'\n", pi)
  225. }
  226. }
  227. }
  228. func handleMux(wg *sync.WaitGroup) http.HandlerFunc {
  229. return func(w http.ResponseWriter, r *http.Request) {
  230. defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
  231. // w.Header().Set("Server", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
  232. // w.Header().Set("X-Powered-By", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
  233. now := time.Now()
  234. // check if the request is from a banned client
  235. if banned, err := isBanned(r, now); err != nil || banned {
  236. if err != nil {
  237. http.Error(w, "Error: "+err.Error(), http.StatusInternalServerError)
  238. } else {
  239. w.Header().Set("Retry-After", "14400") // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.37
  240. // evtl. 429 StatusTooManyRequests?
  241. // or 503 StatusServiceUnavailable?
  242. http.Error(w, "Sorry, banned", http.StatusTooManyRequests)
  243. }
  244. return
  245. }
  246. if !r.URL.IsAbs() {
  247. log.Printf("request URL not absolute >>> %s <<<", r.URL)
  248. }
  249. path_info := os.Getenv("PATH_INFO")
  250. // unpack (nonexisting) static files
  251. func() {
  252. if _, err := os.Stat(filepath.Join(dirApp, "delete_me_to_restore")); !os.IsNotExist(err) {
  253. return
  254. }
  255. defer un(trace("RestoreAssets"))
  256. for _, filename := range AssetNames() {
  257. if filepath.Dir(filename) == "tpl" {
  258. continue
  259. }
  260. if _, err := os.Stat(filename); os.IsNotExist(err) {
  261. if err := RestoreAsset(".", filename); err != nil {
  262. http.Error(w, "failed "+filename+": "+err.Error(), http.StatusInternalServerError)
  263. return
  264. } else {
  265. log.Printf("create %s\n", filename)
  266. }
  267. } else {
  268. log.Printf("keep %s\n", filename)
  269. }
  270. }
  271. // os.Chmod(dirApp, os.FileMode(0750)) // not sure if this is a good idea.
  272. }()
  273. cfg, err := LoadConfig()
  274. if err != nil {
  275. log.Printf("Couldn't load config: %s", err.Error())
  276. http.Error(w, "Couldn't load config: "+err.Error(), http.StatusInternalServerError)
  277. return
  278. }
  279. tz, err := time.LoadLocation(cfg.TimeZone)
  280. if err != nil {
  281. http.Error(w, "Invalid timezone '"+cfg.TimeZone+"': "+err.Error(), http.StatusInternalServerError)
  282. return
  283. }
  284. // get config and session
  285. app := Server{cfg: cfg, tz: tz}
  286. {
  287. app.cgi = func(u url.URL, cgi string) url.URL {
  288. u.Path = cgi
  289. u.RawQuery = ""
  290. return u
  291. }(*r.URL, os.Getenv("SCRIPT_NAME"))
  292. app.url = app.cgi
  293. app.url.Path = path.Dir(app.cgi.Path)
  294. if !strings.HasSuffix(app.url.Path, "/") {
  295. app.url.Path += "/"
  296. }
  297. var err error
  298. var buf []byte
  299. if buf, err = base64.StdEncoding.DecodeString(app.cfg.CookieStoreSecret); err != nil {
  300. http.Error(w, "Couldn't get seed: "+err.Error(), http.StatusInternalServerError)
  301. return
  302. } else {
  303. // what if the cookie has changed? Ignore cookie errors, especially on new/changed keys.
  304. app.ses, _ = sessions.NewCookieStore(buf).Get(r, "ShaarliGo")
  305. app.ses.Options = &sessions.Options{
  306. Path: app.url.EscapedPath(), // to match all requests
  307. MaxAge: int(toSession / time.Second),
  308. HttpOnly: true,
  309. SameSite: http.SameSiteNoneMode,
  310. Secure: true,
  311. }
  312. }
  313. }
  314. switch path_info {
  315. case "/about":
  316. http.Redirect(w, r, "about/", http.StatusFound)
  317. return
  318. case "/about/":
  319. w.Header().Set("Content-Type", "text/xml; charset=utf-8")
  320. io.WriteString(w, xml.Header)
  321. io.WriteString(w, `<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
  322. xmlns:rfc="https://tools.ietf.org/html/"
  323. xmlns="http://usefulinc.com/ns/doap#">
  324. <Project>
  325. <name>🌺 ShaarliGo</name>
  326. <audience>Self-hosting Microbloggers</audience>
  327. <short-description xml:lang="en">🌺 self-hosted microblogging inspired by http://sebsauvage.net/wiki/doku.php?id=php:shaarli. Destilled down to the bare minimum, with easy hosting and security in mind. No PHP, no DB, no server-side templating, JS optional.</short-description>
  328. <implements rdf:resource="https://sebsauvage.net/wiki/doku.php?id=php:shaarli"/>
  329. <implements rdf:resource="https://tools.ietf.org/html/rfc4287"/>
  330. <implements rdf:resource="https://tools.ietf.org/html/rfc5005"/>
  331. <!-- implements rdf:resource="https://tools.ietf.org/html/rfc5023"/ -->
  332. <service-endpoint rdf:resource="https://demo.mro.name/shaarligo"/>
  333. <blog rdf:resource="https://demo.mro.name/shaarligo"/>
  334. <platform rdf:resource="https://httpd.apache.org/"/>
  335. <platform rdf:resource="https://www.lighttpd.net/"/>
  336. <platform rdf:resource="https://tools.ietf.org/html/rfc3875"/>
  337. <homepage rdf:resource="http://purl.mro.name/ShaarliGo"/>
  338. <wiki rdf:resource="https://code.mro.name/mro/ShaarliGo/wiki"/>
  339. <bug-database rdf:resource="https://code.mro.name/mro/ShaarliGo/issues"/>
  340. <maintainer rdf:resource="http://mro.name/~me"/>
  341. <programming-language>golang</programming-language>
  342. <programming-language>xslt</programming-language>
  343. <programming-language>js</programming-language>
  344. <category>self-hosting</category>
  345. <category>microblogging</category>
  346. <category>shaarli</category>
  347. <category>nodb</category>
  348. <category>static</category>
  349. <category>atom</category>
  350. <category>cgi</category>
  351. <repository>
  352. <GitRepository>
  353. <browse rdf:resource="https://code.mro.name/mro/ShaarliGo"/>
  354. <location rdf:resource="https://code.mro.name/mro/ShaarliGo.git"/>
  355. </GitRepository>
  356. </repository>
  357. <release>
  358. <Version>
  359. <name>`+version+"+"+GitSHA1+`</name>
  360. <revision>`+GitSHA1+`</revision>
  361. <description xml:lang="en">…</description>
  362. </Version>
  363. </release>
  364. </Project>
  365. </rdf:RDF>`)
  366. return
  367. case "/config/":
  368. // make a 404 (fallthrough) if already configured but not currently logged in
  369. if !app.cfg.IsConfigured() || app.IsLoggedIn(now) {
  370. app.KeepAlive(w, r, now)
  371. app.handleSettings()(w, r)
  372. return
  373. }
  374. case "/session/":
  375. // maybe cache a bit, but never KeepAlive
  376. if app.IsLoggedIn(now) {
  377. w.Header().Set("Content-Type", "text/plain; charset=utf-8")
  378. // w.Header().Set("Etag", r.URL.Path)
  379. // w.Header().Set("Cache-Control", "max-age=59") // 59 Seconds
  380. io.WriteString(w, app.cfg.Uid)
  381. } else {
  382. // don't squeal to ban.
  383. http.NotFound(w, r)
  384. }
  385. return
  386. case "":
  387. app.KeepAlive(w, r, now)
  388. params := r.URL.Query()
  389. switch {
  390. case "" == r.URL.RawQuery && !app.cfg.IsConfigured():
  391. http.Redirect(w, r, path.Join(r.URL.Path, "config")+"/", http.StatusSeeOther)
  392. return
  393. // legacy API, https://code.mro.name/mro/Shaarli-API-test
  394. case 1 == len(params["post"]) ||
  395. ("" == r.URL.RawQuery && r.Method == http.MethodPost && r.FormValue("save_edit") == "Save"):
  396. app.handleDoPost(app.Posse)(w, r)
  397. return
  398. case (1 == len(params["do"]) && "login" == params["do"][0]) ||
  399. (http.MethodPost == r.Method && "" != r.FormValue("login")): // really. https://github.com/sebsauvage/Shaarli/blob/master/index.php#L402
  400. app.handleDoLogin()(w, r)
  401. return
  402. case 1 == len(params["do"]) && "logout" == params["do"][0]:
  403. app.handleDoLogout()(w, r)
  404. return
  405. case 1 == len(params["do"]) && "configure" == params["do"][0]:
  406. http.Redirect(w, r, path.Join(r.URL.Path, "config")+"/", http.StatusSeeOther)
  407. return
  408. case 1 == len(params["do"]) && "changepasswd" == params["do"][0]:
  409. app.handleDoCheckLoginAfterTheFact()(w, r)
  410. return
  411. case 1 == len(params):
  412. // redirect legacy Ids [A-Za-z0-9_-]{6} in case
  413. for k, v := range params {
  414. if 1 == len(v) && "" == v[0] && len(k) == 6 {
  415. if id, err := base64ToBase24x7(k); err != nil {
  416. http.Error(w, "Invalid Id '"+k+"': "+err.Error(), http.StatusNotAcceptable)
  417. } else {
  418. log.Printf("shaarli_go_path_0 + \"?(%[1]s|\\?)%[2]s/?$\" => \"%[1]s%[3]s/\",\n", uriPubPosts, k, id)
  419. http.Redirect(w, r, path.Join(r.URL.Path, "..", uriPub, uriPosts, id)+"/", http.StatusMovedPermanently)
  420. }
  421. return
  422. }
  423. }
  424. }
  425. case "/search/":
  426. app.handleSearch()(w, r)
  427. return
  428. case "/tools/":
  429. app.handleTools()(w, r)
  430. return
  431. }
  432. squealFailure(r, now, "404")
  433. http.NotFound(w, r)
  434. }
  435. }