123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- //
- // Copyright (C) 2017-2021 Marcus Rohrmoser, http://purl.mro.name/ShaarliGo
- //
- // This program is free software: you can redistribute it and/or modify
- // it under the terms of the GNU General Public License as published by
- // the Free Software Foundation, either version 3 of the License, or
- // (at your option) any later version.
- //
- // This program is distributed in the hope that it will be useful,
- // but WITHOUT ANY WARRANTY; without even the implied warranty of
- // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- // GNU General Public License for more details.
- //
- // You should have received a copy of the GNU General Public License
- // along with this program. If not, see <http://www.gnu.org/licenses/>.
- //
- // Files & Directories
- //
- // .htaccess
- // shaarligo.cgi
- // app/.htaccess
- // app/config.yaml
- // app/posts.gob.gz
- // app/posts.xml.gz
- // app/var/bans.yaml
- // app/var/error.log
- // app/var/stage/
- // app/var/old/
- // themes/current/
- // o/p/
- package main
- import (
- "encoding/base64"
- "encoding/gob"
- "encoding/xml"
- "fmt"
- "io"
- "log"
- "net/http"
- "net/http/cgi"
- "net/url"
- "os"
- "path"
- "path/filepath"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/gorilla/sessions"
- )
- const toSession = 30 * time.Minute
- const myselfNamespace = "http://purl.mro.name/ShaarliGo/"
- 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
- var fileFeedStorage string
- func init() {
- fileFeedStorage = filepath.Join(dirApp, "var", uriPub+".atom")
- gob.Register(Id("")) // http://www.gorillatoolkit.org/pkg/sessions
- }
- // even cooler: https://stackoverflow.com/a/8363629
- //
- // inspired by // https://coderwall.com/p/cp5fya/measuring-execution-time-in-go
- func trace(name string) (string, time.Time) { return name, time.Now() }
- func un(name string, start time.Time) { log.Printf("%s took %s", name, time.Since(start)) }
- func LoadFeed() (Feed, error) {
- defer un(trace("LoadFeed"))
- if feed, err := FeedFromFileName(fileFeedStorage); err != nil {
- return feed, err
- } else {
- for _, ent := range feed.Entries {
- if 6 == len(ent.Id) {
- if id, err := base64ToBase24x7(string(ent.Id)); err != nil {
- log.Printf("Error converting id \"%s\": %s\n", ent.Id, err)
- } else {
- log.Printf("shaarli_go_path_0 + \"?(%[1]s|\\?)%[2]s/?$\" => \"%[1]s%[3]s/\",\n", uriPubPosts, ent.Id, id)
- ent.Id = Id(id)
- }
- }
- }
- return feed, nil
- }
- }
- // are we running cli
- func runCli() bool {
- if 0 != len(os.Getenv("REQUEST_METHOD")) {
- return false
- }
- fmt.Printf("%sv%s+%s#:\n", myselfNamespace, version, GitSHA1)
- cfg, err := LoadConfig()
- if err != nil {
- panic(err)
- }
- fmt.Printf(" timezone: %s\n", cfg.TimeZone)
- feed, err := LoadFeed()
- if os.IsNotExist(err) {
- cwd, _ := os.Getwd()
- fmt.Fprintf(os.Stderr, "%s: cannot access %s: No such file or directory\n", filepath.Base(os.Args[0]), filepath.Join(cwd, fileFeedStorage))
- os.Exit(1)
- return true
- }
- if err != nil {
- panic(err)
- }
- fmt.Printf(" posts: %d\n", len(feed.Entries))
- //fmt.Printf(" tags: %d\n", len(feed.Categories))
- if 0 < len(feed.Entries) {
- fmt.Printf(" first: %v\n", feed.Entries[len(feed.Entries)-1].Published.Format(time.RFC3339))
- fmt.Printf(" last: %v\n", feed.Entries[0].Published.Format(time.RFC3339))
- }
- return true
- }
- // evtl. as a server, too: http://www.dav-muz.net/blog/2013/09/how-to-use-go-and-fastcgi/
- func main() {
- if runCli() {
- return
- }
- if false {
- // lighttpd doesn't seem to like more than one (per-vhost) server.breakagelog
- log.SetOutput(os.Stderr)
- } else { // log to custom logfile rather than stderr (may not be reachable on shared hosting)
- dst := filepath.Join(dirApp, "var", "log", "error.log")
- if err := os.MkdirAll(filepath.Dir(dst), 0770); err != nil {
- log.Fatal("Couldn't create app/var/log dir: " + err.Error())
- return
- }
- if fileLog, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0660); err != nil {
- log.Fatal("Couldn't open logfile: " + err.Error())
- return
- } else {
- defer fileLog.Close()
- log.SetOutput(fileLog)
- }
- }
- wg := &sync.WaitGroup{}
- // - check non-write perm of program?
- // - check non-http read perm on ./app
- if err := cgi.Serve(http.HandlerFunc(handleMux(wg))); err != nil {
- log.Fatal(err)
- }
- wg.Wait()
- }
- type Server struct {
- cfg Config
- ses *sessions.Session
- tz *time.Location
- url url.URL
- cgi url.URL
- }
- func (app *Server) startSession(w http.ResponseWriter, r *http.Request, now time.Time) error {
- app.ses.Values["timeout"] = now.Add(toSession).Unix()
- return app.ses.Save(r, w)
- }
- func (app *Server) stopSession(w http.ResponseWriter, r *http.Request) error {
- delete(app.ses.Values, "timeout")
- return app.ses.Save(r, w)
- }
- func (app *Server) KeepAlive(w http.ResponseWriter, r *http.Request, now time.Time) error {
- if app.IsLoggedIn(now) {
- return app.startSession(w, r, now)
- }
- return nil
- }
- func (app Server) IsLoggedIn(now time.Time) bool {
- // https://gowebexamples.com/sessions/
- // or https://stackoverflow.com/questions/28616830/gorilla-sessions-how-to-automatically-update-cookie-expiration-on-request
- timeout, ok := app.ses.Values["timeout"].(int64)
- return ok && now.Before(time.Unix(timeout, 0))
- }
- // Internal storage, not publishing.
- func (app Server) SaveFeed(feed Feed) error {
- defer un(trace("Server.SaveFeed"))
- feed.Id = ""
- feed.XmlBase = ""
- feed.Generator = nil
- feed.Updated = iso8601{}
- feed.Categories = nil
- return feed.SaveToFile(fileFeedStorage)
- }
- func (app Server) Posse(en Entry) {
- defer un(trace("Server.Posse"))
- to := 4 * time.Second
- back := func(prefix string, base url.URL, id Id) string {
- if "" == prefix {
- prefix = "¹ " + base.String() + uriPubPosts
- }
- return prefix + string(id)
- }
- for _, po := range app.cfg.Posse {
- switch pi := po.(type) {
- case Pinboard:
- if ep, err := url.Parse(pi.Endpoint); err != nil {
- log.Printf("- posse %s error %s\n", pi.Endpoint, err)
- } else {
- if ur, err := pinboardPostsAdd(*ep, en, back(pi.Prefix, app.url, en.Id)); err != nil {
- log.Printf("- posse %s error %s\n", ep, err)
- } else {
- if _, err := HttpGetBody(&ur, to); err != nil {
- log.Printf("- posse %s error %s\n", ur.String(), err)
- } else {
- // TODO: check response
- log.Printf("- posse %s\n", ur.String())
- }
- }
- }
- case Mastodon:
- max := func(x, y int) int {
- if x < y {
- return y
- }
- return x
- }
- if ep, err := url.Parse(pi.Endpoint); err != nil {
- log.Printf("- posse %s error %s\n", pi.Endpoint, err)
- } else {
- limit, _ := strconv.Atoi(pi.Limit)
- if limit <= 0 {
- limit = 500 // default
- }
- limit = max(100, limit) // mimimum
- if err := mastodonStatusPost(*ep, pi.Token, limit, en, back(pi.Prefix, app.url, en.Id)); err != nil {
- log.Printf("- posse %s error %s\n", ep, err)
- }
- }
- default:
- log.Printf("I don't know about type '%T'\n", pi)
- }
- }
- }
- func handleMux(wg *sync.WaitGroup) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
- // w.Header().Set("Server", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
- // w.Header().Set("X-Powered-By", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
- now := time.Now()
- // check if the request is from a banned client
- if banned, err := isBanned(r, now); err != nil || banned {
- if err != nil {
- http.Error(w, "Error: "+err.Error(), http.StatusInternalServerError)
- } else {
- w.Header().Set("Retry-After", "14400") // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.37
- // evtl. 429 StatusTooManyRequests?
- // or 503 StatusServiceUnavailable?
- http.Error(w, "Sorry, banned", http.StatusTooManyRequests)
- }
- return
- }
- if !r.URL.IsAbs() {
- log.Printf("request URL not absolute >>> %s <<<", r.URL)
- }
- path_info := os.Getenv("PATH_INFO")
- // unpack (nonexisting) static files
- func() {
- if _, err := os.Stat(filepath.Join(dirApp, "delete_me_to_restore")); !os.IsNotExist(err) {
- return
- }
- defer un(trace("RestoreAssets"))
- for _, filename := range AssetNames() {
- if filepath.Dir(filename) == "tpl" {
- continue
- }
- if _, err := os.Stat(filename); os.IsNotExist(err) {
- if err := RestoreAsset(".", filename); err != nil {
- http.Error(w, "failed "+filename+": "+err.Error(), http.StatusInternalServerError)
- return
- } else {
- log.Printf("create %s\n", filename)
- }
- } else {
- log.Printf("keep %s\n", filename)
- }
- }
- // os.Chmod(dirApp, os.FileMode(0750)) // not sure if this is a good idea.
- }()
- cfg, err := LoadConfig()
- if err != nil {
- log.Printf("Couldn't load config: %s", err.Error())
- http.Error(w, "Couldn't load config: "+err.Error(), http.StatusInternalServerError)
- return
- }
- tz, err := time.LoadLocation(cfg.TimeZone)
- if err != nil {
- http.Error(w, "Invalid timezone '"+cfg.TimeZone+"': "+err.Error(), http.StatusInternalServerError)
- return
- }
- // get config and session
- app := Server{cfg: cfg, tz: tz}
- {
- app.cgi = func(u url.URL, cgi string) url.URL {
- u.Path = cgi
- u.RawQuery = ""
- return u
- }(*r.URL, os.Getenv("SCRIPT_NAME"))
- app.url = app.cgi
- app.url.Path = path.Dir(app.cgi.Path)
- if !strings.HasSuffix(app.url.Path, "/") {
- app.url.Path += "/"
- }
- var err error
- var buf []byte
- if buf, err = base64.StdEncoding.DecodeString(app.cfg.CookieStoreSecret); err != nil {
- http.Error(w, "Couldn't get seed: "+err.Error(), http.StatusInternalServerError)
- return
- } else {
- // what if the cookie has changed? Ignore cookie errors, especially on new/changed keys.
- app.ses, _ = sessions.NewCookieStore(buf).Get(r, "ShaarliGo")
- app.ses.Options = &sessions.Options{
- Path: app.url.EscapedPath(), // to match all requests
- MaxAge: int(toSession / time.Second),
- HttpOnly: true,
- SameSite: http.SameSiteNoneMode,
- Secure: true,
- }
- }
- }
- switch path_info {
- case "/about":
- http.Redirect(w, r, "about/", http.StatusFound)
- return
- case "/about/":
- w.Header().Set("Content-Type", "text/xml; charset=utf-8")
- io.WriteString(w, xml.Header)
- io.WriteString(w, `<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
- xmlns:rfc="https://tools.ietf.org/html/"
- xmlns="http://usefulinc.com/ns/doap#">
- <Project>
- <name>🌺 ShaarliGo</name>
- <audience>Self-hosting Microbloggers</audience>
- <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>
- <implements rdf:resource="https://sebsauvage.net/wiki/doku.php?id=php:shaarli"/>
- <implements rdf:resource="https://tools.ietf.org/html/rfc4287"/>
- <implements rdf:resource="https://tools.ietf.org/html/rfc5005"/>
- <!-- implements rdf:resource="https://tools.ietf.org/html/rfc5023"/ -->
- <service-endpoint rdf:resource="https://demo.mro.name/shaarligo"/>
- <blog rdf:resource="https://demo.mro.name/shaarligo"/>
- <platform rdf:resource="https://httpd.apache.org/"/>
- <platform rdf:resource="https://www.lighttpd.net/"/>
- <platform rdf:resource="https://tools.ietf.org/html/rfc3875"/>
- <homepage rdf:resource="http://purl.mro.name/ShaarliGo"/>
- <wiki rdf:resource="https://code.mro.name/mro/ShaarliGo/wiki"/>
- <bug-database rdf:resource="https://code.mro.name/mro/ShaarliGo/issues"/>
- <maintainer rdf:resource="http://mro.name/~me"/>
- <programming-language>golang</programming-language>
- <programming-language>xslt</programming-language>
- <programming-language>js</programming-language>
- <category>self-hosting</category>
- <category>microblogging</category>
- <category>shaarli</category>
- <category>nodb</category>
- <category>static</category>
- <category>atom</category>
- <category>cgi</category>
- <repository>
- <GitRepository>
- <browse rdf:resource="https://code.mro.name/mro/ShaarliGo"/>
- <location rdf:resource="https://code.mro.name/mro/ShaarliGo.git"/>
- </GitRepository>
- </repository>
- <release>
- <Version>
- <name>`+version+"+"+GitSHA1+`</name>
- <revision>`+GitSHA1+`</revision>
- <description xml:lang="en">…</description>
- </Version>
- </release>
- </Project>
- </rdf:RDF>`)
- return
- case "/config/":
- // make a 404 (fallthrough) if already configured but not currently logged in
- if !app.cfg.IsConfigured() || app.IsLoggedIn(now) {
- app.KeepAlive(w, r, now)
- app.handleSettings()(w, r)
- return
- }
- case "/session/":
- // maybe cache a bit, but never KeepAlive
- if app.IsLoggedIn(now) {
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- // w.Header().Set("Etag", r.URL.Path)
- // w.Header().Set("Cache-Control", "max-age=59") // 59 Seconds
- io.WriteString(w, app.cfg.Uid)
- } else {
- // don't squeal to ban.
- http.NotFound(w, r)
- }
- return
- case "":
- app.KeepAlive(w, r, now)
- params := r.URL.Query()
- switch {
- case "" == r.URL.RawQuery && !app.cfg.IsConfigured():
- http.Redirect(w, r, path.Join(r.URL.Path, "config")+"/", http.StatusSeeOther)
- return
- // legacy API, https://code.mro.name/mro/Shaarli-API-test
- case 1 == len(params["post"]) ||
- ("" == r.URL.RawQuery && r.Method == http.MethodPost && r.FormValue("save_edit") == "Save"):
- app.handleDoPost(app.Posse)(w, r)
- return
- case (1 == len(params["do"]) && "login" == params["do"][0]) ||
- (http.MethodPost == r.Method && "" != r.FormValue("login")): // really. https://github.com/sebsauvage/Shaarli/blob/master/index.php#L402
- app.handleDoLogin()(w, r)
- return
- case 1 == len(params["do"]) && "logout" == params["do"][0]:
- app.handleDoLogout()(w, r)
- return
- case 1 == len(params["do"]) && "configure" == params["do"][0]:
- http.Redirect(w, r, path.Join(r.URL.Path, "config")+"/", http.StatusSeeOther)
- return
- case 1 == len(params["do"]) && "changepasswd" == params["do"][0]:
- app.handleDoCheckLoginAfterTheFact()(w, r)
- return
- case 1 == len(params):
- // redirect legacy Ids [A-Za-z0-9_-]{6} in case
- for k, v := range params {
- if 1 == len(v) && "" == v[0] && len(k) == 6 {
- if id, err := base64ToBase24x7(k); err != nil {
- http.Error(w, "Invalid Id '"+k+"': "+err.Error(), http.StatusNotAcceptable)
- } else {
- log.Printf("shaarli_go_path_0 + \"?(%[1]s|\\?)%[2]s/?$\" => \"%[1]s%[3]s/\",\n", uriPubPosts, k, id)
- http.Redirect(w, r, path.Join(r.URL.Path, "..", uriPub, uriPosts, id)+"/", http.StatusMovedPermanently)
- }
- return
- }
- }
- }
- case "/search/":
- app.handleSearch()(w, r)
- return
- case "/tools/":
- app.handleTools()(w, r)
- return
- }
- squealFailure(r, now, "404")
- http.NotFound(w, r)
- }
- }
|