ShaarliGo.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  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. // 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. // assets/default/de/
  30. // o/p/
  31. //
  32. package main
  33. import (
  34. "encoding/base64"
  35. "encoding/gob"
  36. "encoding/xml"
  37. "fmt"
  38. "io"
  39. "log"
  40. "net/http"
  41. "net/http/cgi"
  42. "net/url"
  43. "os"
  44. "path"
  45. "path/filepath"
  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 handleMux(wg *sync.WaitGroup) http.HandlerFunc {
  179. return func(w http.ResponseWriter, r *http.Request) {
  180. defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
  181. // w.Header().Set("Server", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
  182. // w.Header().Set("X-Powered-By", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
  183. now := time.Now()
  184. // check if the request is from a banned client
  185. if banned, err := isBanned(r, now); err != nil || banned {
  186. if err != nil {
  187. http.Error(w, "Error: "+err.Error(), http.StatusInternalServerError)
  188. } else {
  189. http.Error(w, "Sorry, banned", http.StatusNotAcceptable)
  190. }
  191. return
  192. }
  193. if !r.URL.IsAbs() {
  194. log.Printf("request URL not absolute >>> %s <<<", r.URL)
  195. }
  196. cfg, err := LoadConfig()
  197. if err != nil {
  198. http.Error(w, "Couldn't load config: "+err.Error(), http.StatusInternalServerError)
  199. return
  200. }
  201. tz, err := time.LoadLocation(cfg.TimeZone)
  202. if err != nil {
  203. http.Error(w, "Invalid timezone '"+cfg.TimeZone+"': "+err.Error(), http.StatusInternalServerError)
  204. return
  205. }
  206. path_info := os.Getenv("PATH_INFO")
  207. // unpack (nonexisting) static files
  208. func() {
  209. if _, err := os.Stat(filepath.Join(dirApp, "delete_me_to_restore")); !os.IsNotExist(err) {
  210. return
  211. }
  212. defer un(trace("RestoreAssets"))
  213. for _, filename := range AssetNames() {
  214. if filepath.Dir(filename) == "tpl" {
  215. continue
  216. }
  217. if _, err := os.Stat(filename); os.IsNotExist(err) {
  218. if err := RestoreAsset(".", filename); err != nil {
  219. http.Error(w, "failed "+filename+": "+err.Error(), http.StatusInternalServerError)
  220. return
  221. } else {
  222. log.Printf("create %s\n", filename)
  223. }
  224. } else {
  225. log.Printf("keep %s\n", filename)
  226. }
  227. }
  228. // os.Chmod(dirApp, os.FileMode(0750)) // not sure if this is a good idea.
  229. }()
  230. // get config and session
  231. app := Server{cfg: cfg, tz: tz}
  232. {
  233. app.cgi = func(u url.URL, cgi string) url.URL {
  234. u.Path = cgi
  235. u.RawQuery = ""
  236. return u
  237. }(*r.URL, os.Getenv("SCRIPT_NAME"))
  238. app.url = app.cgi
  239. app.url.Path = path.Dir(app.cgi.Path)
  240. if !strings.HasSuffix(app.url.Path, "/") {
  241. app.url.Path += "/"
  242. }
  243. var err error
  244. var buf []byte
  245. if buf, err = base64.StdEncoding.DecodeString(app.cfg.CookieStoreSecret); err != nil {
  246. http.Error(w, "Couldn't get seed: "+err.Error(), http.StatusInternalServerError)
  247. return
  248. } else {
  249. // what if the cookie has changed? Ignore cookie errors, especially on new/changed keys.
  250. app.ses, _ = sessions.NewCookieStore(buf).Get(r, "ShaarliGo")
  251. app.ses.Options = &sessions.Options{
  252. Path: app.url.EscapedPath(), // to match all requests
  253. MaxAge: int(toSession / time.Second),
  254. HttpOnly: true,
  255. }
  256. }
  257. }
  258. switch path_info {
  259. case "/about":
  260. http.Redirect(w, r, "about/", http.StatusFound)
  261. return
  262. case "/about/":
  263. w.Header().Set("Content-Type", "text/xml; charset=utf-8")
  264. io.WriteString(w, xml.Header)
  265. io.WriteString(w, `<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
  266. xmlns:rfc="https://tools.ietf.org/html/"
  267. xmlns="http://usefulinc.com/ns/doap#">
  268. <Project>
  269. <name>🌺 ShaarliGo</name>
  270. <audience>Self-hosting Microbloggers</audience>
  271. <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>
  272. <implements rdf:resource="https://sebsauvage.net/wiki/doku.php?id=php:shaarli"/>
  273. <implements rdf:resource="https://tools.ietf.org/html/rfc4287"/>
  274. <implements rdf:resource="https://tools.ietf.org/html/rfc5005"/>
  275. <!-- implements rdf:resource="https://tools.ietf.org/html/rfc5023"/ -->
  276. <service-endpoint rdf:resource="https://demo.mro.name/shaarligo"/>
  277. <blog rdf:resource="https://demo.mro.name/shaarligo"/>
  278. <platform rdf:resource="https://httpd.apache.org/"/>
  279. <platform rdf:resource="https://www.lighttpd.net/"/>
  280. <platform rdf:resource="https://tools.ietf.org/html/rfc3875"/>
  281. <homepage rdf:resource="http://purl.mro.name/ShaarliGo"/>
  282. <wiki rdf:resource="https://code.mro.name/mro/ShaarliGo/wiki"/>
  283. <bug-database rdf:resource="https://code.mro.name/mro/ShaarliGo/issues"/>
  284. <maintainer rdf:resource="http://mro.name/~me"/>
  285. <programming-language>golang</programming-language>
  286. <programming-language>xslt</programming-language>
  287. <programming-language>js</programming-language>
  288. <category>self-hosting</category>
  289. <category>microblogging</category>
  290. <category>shaarli</category>
  291. <category>nodb</category>
  292. <category>static</category>
  293. <category>atom</category>
  294. <category>cgi</category>
  295. <repository>
  296. <GitRepository>
  297. <browse rdf:resource="https://code.mro.name/mro/ShaarliGo"/>
  298. <location rdf:resource="https://code.mro.name/mro/ShaarliGo.git"/>
  299. </GitRepository>
  300. </repository>
  301. <release>
  302. <Version>
  303. <name>`+version+"+"+GitSHA1+`</name>
  304. <revision>`+GitSHA1+`</revision>
  305. <description xml:lang="en">…</description>
  306. </Version>
  307. </release>
  308. </Project>
  309. </rdf:RDF>`)
  310. return
  311. case "/config/":
  312. // make a 404 (fallthrough) if already configured but not currently logged in
  313. if !app.cfg.IsConfigured() || app.IsLoggedIn(now) {
  314. app.KeepAlive(w, r, now)
  315. app.handleSettings()(w, r)
  316. return
  317. }
  318. case "/session/":
  319. // maybe cache a bit, but never KeepAlive
  320. if app.IsLoggedIn(now) {
  321. w.Header().Set("Content-Type", "text/plain; charset=utf-8")
  322. // w.Header().Set("Etag", r.URL.Path)
  323. // w.Header().Set("Cache-Control", "max-age=59") // 59 Seconds
  324. io.WriteString(w, app.cfg.Uid)
  325. } else {
  326. // don't squeal to ban.
  327. http.NotFound(w, r)
  328. }
  329. return
  330. case "":
  331. app.KeepAlive(w, r, now)
  332. params := r.URL.Query()
  333. switch {
  334. case "" == r.URL.RawQuery && !app.cfg.IsConfigured():
  335. http.Redirect(w, r, path.Join(r.URL.Path, "config")+"/", http.StatusSeeOther)
  336. return
  337. // legacy API, https://code.mro.name/mro/Shaarli-API-test
  338. case 1 == len(params["post"]):
  339. app.handleDoPost()(w, r)
  340. return
  341. case (1 == len(params["do"]) && "login" == params["do"][0]) ||
  342. (http.MethodPost == r.Method && "" != r.FormValue("login")): // really. https://github.com/sebsauvage/Shaarli/blob/master/index.php#L402
  343. app.handleDoLogin()(w, r)
  344. return
  345. case 1 == len(params["do"]) && "logout" == params["do"][0]:
  346. app.handleDoLogout()(w, r)
  347. return
  348. case 1 == len(params["do"]) && "changepasswd" == params["do"][0]:
  349. app.handleDoCheckLoginAfterTheFact()(w, r)
  350. return
  351. case 1 == len(params):
  352. // redirect legacy Ids [A-Za-z0-9_-]{6} in case
  353. for k, v := range params {
  354. if 1 == len(v) && "" == v[0] && len(k) == 6 {
  355. if id, err := base64ToBase24x7(k); err != nil {
  356. http.Error(w, "Invalid Id '"+k+"': "+err.Error(), http.StatusNotAcceptable)
  357. } else {
  358. log.Printf("shaarli_go_path_0 + \"?(%[1]s|\\?)%[2]s/?$\" => \"%[1]s%[3]s/\",\n", uriPubPosts, k, id)
  359. http.Redirect(w, r, path.Join(r.URL.Path, "..", uriPub, uriPosts, id)+"/", http.StatusMovedPermanently)
  360. }
  361. return
  362. }
  363. }
  364. }
  365. case "/search/":
  366. app.handleSearch()(w, r)
  367. return
  368. case "/tools/":
  369. app.handleTools()(w, r)
  370. return
  371. }
  372. squealFailure(r, now, "404")
  373. http.NotFound(w, r)
  374. }
  375. }