ShaarliGo.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  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. // 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. // pub/posts/
  31. //
  32. package main
  33. import (
  34. "encoding/base64"
  35. "io"
  36. "log"
  37. "net/http"
  38. "net/http/cgi"
  39. "os"
  40. "path"
  41. "path/filepath"
  42. "strings"
  43. "time"
  44. "github.com/gorilla/sessions"
  45. )
  46. const toSession = 30 * time.Minute
  47. const myselfNamespace = "http://purl.mro.name/ShaarliGo/"
  48. 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
  49. var fileFeedStorage string
  50. func init() {
  51. fileFeedStorage = filepath.Join(dirApp, "var", uriPub+".atom")
  52. }
  53. // even cooler: https://stackoverflow.com/a/8363629
  54. //
  55. // inspired by // https://coderwall.com/p/cp5fya/measuring-execution-time-in-go
  56. func trace(name string) (string, time.Time) { return name, time.Now() }
  57. func un(name string, start time.Time) { log.Printf("%s took %s", name, time.Since(start)) }
  58. // evtl. as a server, too: http://www.dav-muz.net/blog/2013/09/how-to-use-go-and-fastcgi/
  59. func main() {
  60. if false {
  61. // lighttpd doesn't seem to like more than one (per-vhost) server.breakagelog
  62. log.SetOutput(os.Stderr)
  63. } else { // log to custom logfile rather than stderr (may not be reachable on shared hosting)
  64. dst := filepath.Join(dirApp, "var", "log", "error.log")
  65. if err := os.MkdirAll(filepath.Dir(dst), 0770); err != nil {
  66. log.Fatal("Couldn't create app/var/log dir: " + err.Error())
  67. return
  68. }
  69. if fileLog, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0660); err != nil {
  70. log.Fatal("Couldn't open logfile: " + err.Error())
  71. return
  72. } else {
  73. defer fileLog.Close()
  74. log.SetOutput(fileLog)
  75. }
  76. }
  77. // - check non-write perm of program?
  78. // - check non-http read perm on ./app
  79. if err := cgi.Serve(http.HandlerFunc(handleMux)); err != nil {
  80. log.Fatal(err)
  81. }
  82. }
  83. type App struct {
  84. cfg Config
  85. ses *sessions.Session
  86. tz *time.Location
  87. }
  88. func (app *App) startSession(w http.ResponseWriter, r *http.Request, now time.Time) error {
  89. app.ses.Values["timeout"] = now.Add(toSession).Unix()
  90. return app.ses.Save(r, w)
  91. }
  92. func (app *App) stopSession(w http.ResponseWriter, r *http.Request) error {
  93. delete(app.ses.Values, "timeout")
  94. return app.ses.Save(r, w)
  95. }
  96. func (app *App) KeepAlive(w http.ResponseWriter, r *http.Request, now time.Time) error {
  97. if app.IsLoggedIn(now) {
  98. return app.startSession(w, r, now)
  99. }
  100. return nil
  101. }
  102. func (app App) IsLoggedIn(now time.Time) bool {
  103. // https://gowebexamples.com/sessions/
  104. // or https://stackoverflow.com/questions/28616830/gorilla-sessions-how-to-automatically-update-cookie-expiration-on-request
  105. timeout, ok := app.ses.Values["timeout"].(int64)
  106. return ok && now.Before(time.Unix(timeout, 0))
  107. }
  108. func (app App) LoadFeed() (Feed, error) {
  109. defer un(trace("App.LoadFeed"))
  110. if feed, err := FeedFromFileName(fileFeedStorage); err != nil {
  111. return feed, err
  112. } else {
  113. for _, ent := range feed.Entries {
  114. if 6 == len(ent.Id) {
  115. if id, err := base64ToBase24x7(ent.Id); err != nil {
  116. log.Printf("Error converting id \"%s\": %s\n", ent.Id, err)
  117. } else {
  118. log.Printf("shaarli_go_path_0 + \"?(%[1]s|\\?)%[2]s/?$\" => \"%[1]s%[3]s/\",\n", uriPubPosts, ent.Id, id)
  119. ent.Id = id
  120. }
  121. }
  122. }
  123. return feed, nil
  124. }
  125. }
  126. // Internal storage, not publishing.
  127. func (app App) SaveFeed(feed Feed) error {
  128. defer un(trace("App.SaveFeed"))
  129. feed.Id = ""
  130. feed.XmlBase = ""
  131. feed.Generator = nil
  132. feed.Updated = iso8601{}
  133. feed.Categories = nil
  134. return feed.SaveToFile(fileFeedStorage)
  135. }
  136. func handleMux(w http.ResponseWriter, r *http.Request) {
  137. defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
  138. // w.Header().Set("Server", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
  139. // w.Header().Set("X-Powered-By", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
  140. now := time.Now()
  141. // check if the request is from a banned client
  142. if banned, err := isBanned(r, now); err != nil || banned {
  143. if err != nil {
  144. http.Error(w, "Error: "+err.Error(), http.StatusInternalServerError)
  145. } else {
  146. http.Error(w, "Sorry, banned", http.StatusNotAcceptable)
  147. }
  148. return
  149. }
  150. path_info := os.Getenv("PATH_INFO")
  151. // script_name :=
  152. urlBase := xmlBaseFromRequestURL(r.URL, os.Getenv("SCRIPT_NAME"))
  153. // unpack (nonexisting) static files
  154. func() {
  155. if _, err := os.Stat(filepath.Join(dirApp, "delete_me_to_restore")); !os.IsNotExist(err) {
  156. return
  157. }
  158. defer un(trace("RestoreAssets"))
  159. for _, filename := range AssetNames() {
  160. if _, err := os.Stat(filename); os.IsNotExist(err) {
  161. if err := RestoreAsset(".", filename); err != nil {
  162. http.Error(w, "failed "+filename+": "+err.Error(), http.StatusInternalServerError)
  163. return
  164. } else {
  165. log.Printf("create %s\n", filename)
  166. }
  167. } else {
  168. log.Printf("keep %s\n", filename)
  169. }
  170. }
  171. // os.Chmod(dirApp, os.FileMode(0750)) // not sure if this is a good idea.
  172. }()
  173. // get config and session
  174. app := App{}
  175. {
  176. var err error
  177. if app.cfg, err = LoadConfig(); err != nil {
  178. http.Error(w, "Couldn't load config: "+err.Error(), http.StatusInternalServerError)
  179. return
  180. }
  181. var buf []byte
  182. if buf, err = base64.StdEncoding.DecodeString(app.cfg.CookieStoreSecret); err != nil {
  183. http.Error(w, "Couldn't get seed: "+err.Error(), http.StatusInternalServerError)
  184. return
  185. } else {
  186. // what if the cookie has changed? Ignore cookie errors, especially on new/changed keys.
  187. app.ses, _ = sessions.NewCookieStore(buf).Get(r, "ShaarliGo")
  188. app.ses.Options = &sessions.Options{
  189. Path: urlBase.EscapedPath(), // to match all requests
  190. MaxAge: int(toSession / time.Second),
  191. HttpOnly: true,
  192. }
  193. }
  194. if app.tz, err = time.LoadLocation(app.cfg.TimeZone); err != nil {
  195. http.Error(w, "Invalid timezone '"+app.cfg.TimeZone+"': "+err.Error(), http.StatusInternalServerError)
  196. return
  197. }
  198. }
  199. switch path_info {
  200. case "/config/":
  201. // make a 404 (fallthrough) if already configured but not currently logged in
  202. if !app.cfg.IsConfigured() || app.IsLoggedIn(now) {
  203. app.KeepAlive(w, r, now)
  204. app.handleSettings(w, r)
  205. return
  206. }
  207. case "/session/":
  208. // maybe cache a bit, but never KeepAlive
  209. if app.IsLoggedIn(now) {
  210. w.Header().Set("Content-Type", "text/plain; charset=utf-8")
  211. // w.Header().Set("Etag", r.URL.Path)
  212. // w.Header().Set("Cache-Control", "max-age=59") // 59 Seconds
  213. io.WriteString(w, app.cfg.Uid)
  214. } else {
  215. // don't squeal to ban.
  216. http.NotFound(w, r)
  217. }
  218. return
  219. case "":
  220. app.KeepAlive(w, r, now)
  221. params := r.URL.Query()
  222. switch {
  223. case "" == r.URL.RawQuery && !app.cfg.IsConfigured():
  224. http.Redirect(w, r, path.Join(r.URL.Path, "config")+"/", http.StatusSeeOther)
  225. return
  226. // legacy API, https://code.mro.name/mro/Shaarli-API-test
  227. case 1 == len(params["post"]):
  228. app.handleDoPost(w, r)
  229. return
  230. case (1 == len(params["do"]) && "login" == params["do"][0]) ||
  231. (http.MethodPost == r.Method && "" != r.FormValue("login")): // really. https://github.com/sebsauvage/Shaarli/blob/master/index.php#L402
  232. app.handleDoLogin(w, r)
  233. return
  234. case 1 == len(params["do"]) && "logout" == params["do"][0]:
  235. app.handleDoLogout(w, r)
  236. return
  237. case 1 == len(params["do"]) && "changepasswd" == params["do"][0]:
  238. app.handleDoCheckLoginAfterTheFact(w, r)
  239. return
  240. case 1 == len(params):
  241. // redirect legacy Ids [A-Za-z0-9_-]{6} in case
  242. for k, v := range params {
  243. if 1 == len(v) && "" == v[0] && len(k) == 6 {
  244. if id, err := base64ToBase24x7(k); err != nil {
  245. http.Error(w, "Invalid Id '"+k+"': "+err.Error(), http.StatusNotAcceptable)
  246. } else {
  247. log.Printf("shaarli_go_path_0 + \"?(%[1]s|\\?)%[2]s/?$\" => \"%[1]s%[3]s/\",\n", uriPubPosts, k, id)
  248. http.Redirect(w, r, path.Join(r.URL.Path, "..", uriPub, uriPosts, id)+"/", http.StatusMovedPermanently)
  249. }
  250. return
  251. }
  252. }
  253. }
  254. case "/search/":
  255. app.handleSearch(w, r)
  256. return
  257. case "/tools/":
  258. app.handleTools(w, r)
  259. return
  260. }
  261. squealFailure(r, now, "404")
  262. http.NotFound(w, r)
  263. }