serve.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. /*
  2. Copyright © 2022 Tone
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as published by
  5. the Free Software Foundation, either version 3 of the License, or
  6. (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <http://www.gnu.org/licenses/>.
  13. */
  14. package cmd
  15. import (
  16. "errors"
  17. "fmt"
  18. "html/template"
  19. "io/ioutil"
  20. "net/http"
  21. "os"
  22. "path/filepath"
  23. "strings"
  24. "time"
  25. "github.com/dustin/go-humanize"
  26. "github.com/justinas/alice"
  27. "github.com/rs/zerolog/hlog"
  28. "github.com/spf13/cobra"
  29. "github.com/spf13/viper"
  30. "github.com/t0n3/sakuin/web"
  31. )
  32. type fileItem struct {
  33. Name string
  34. Size string
  35. Date string
  36. IsDir bool
  37. Path string
  38. }
  39. type templateVariables struct {
  40. Path []breadcrumb
  41. Files []fileItem
  42. }
  43. type breadcrumb struct {
  44. Name string
  45. Path string
  46. }
  47. var dataDir string
  48. // serveCmd represents the serve command
  49. var serveCmd = &cobra.Command{
  50. Use: "serve",
  51. Short: "Start the HTTP server",
  52. Run: serve,
  53. }
  54. func init() {
  55. rootCmd.AddCommand(serveCmd)
  56. serveCmd.Flags().StringP("data", "d", "", "Directory containing data that Sakuin will serve")
  57. serveCmd.Flags().IntP("port", "p", 3000, "Port to listen to")
  58. serveCmd.Flags().String("listen", "0.0.0.0", "Address to listen to")
  59. viper.BindPFlag("data", serveCmd.Flags().Lookup("data"))
  60. viper.BindPFlag("port", serveCmd.Flags().Lookup("port"))
  61. viper.BindPFlag("listen", serveCmd.Flags().Lookup("listen"))
  62. }
  63. func serve(cmd *cobra.Command, args []string) {
  64. dataDir = viper.GetString("data")
  65. if dataDir == "" {
  66. log.Fatal().Err(errors.New("please specify a data directory, can't be empty")).Msg("")
  67. }
  68. _, err := os.Stat(dataDir)
  69. if err != nil {
  70. if os.IsNotExist(err) {
  71. log.Fatal().Err(errors.New("please specify a valid data directory")).Msg("")
  72. }
  73. }
  74. log.Info().Msgf("Sakuin will serve this directory: %s", dataDir)
  75. middleware := alice.New()
  76. // Install the logger handler with default output on the console
  77. middleware = middleware.Append(hlog.NewHandler(log))
  78. // Install some provided extra handler to set some request's context fields.
  79. // Thanks to that handler, all our logs will come with some prepopulated fields.
  80. middleware = middleware.Append(hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) {
  81. hlog.FromRequest(r).Info().
  82. Str("method", r.Method).
  83. Stringer("url", r.URL).
  84. Int("status", status).
  85. Int("size", size).
  86. Dur("duration", duration).
  87. Msg("")
  88. }))
  89. middleware = middleware.Append(hlog.RemoteAddrHandler("ip"))
  90. middleware = middleware.Append(hlog.UserAgentHandler("user_agent"))
  91. middleware = middleware.Append(hlog.RefererHandler("referer"))
  92. middleware = middleware.Append(hlog.RequestIDHandler("req_id", "Request-Id"))
  93. handler := middleware.Then(http.HandlerFunc(serverHandler))
  94. assetsHandler := middleware.Then(web.AssetsHandler("/assets/", "dist"))
  95. port := viper.GetInt("port")
  96. address := viper.GetString("listen")
  97. mux := http.NewServeMux()
  98. mux.Handle("/assets/", assetsHandler)
  99. mux.Handle("/", handler)
  100. log.Info().Msgf("Starting Sakuin HTTP Server on %s:%d", address, port)
  101. http.ListenAndServe(fmt.Sprintf("%s:%d", address, port), mux)
  102. }
  103. func serverHandler(w http.ResponseWriter, r *http.Request) {
  104. // Filepath, from the root data dir
  105. fp := filepath.Join(dataDir, filepath.Clean(r.URL.Path))
  106. // Cleaned filepath, without the root data dir, used for template rendering purpose
  107. cfp := strings.Replace(fp, dataDir, "", 1)
  108. // Return a 404 if the template doesn't exist
  109. info, err := os.Stat(fp)
  110. if err != nil {
  111. if os.IsNotExist(err) {
  112. notFound, _ := template.ParseFS(web.NotFound, "404.html")
  113. w.WriteHeader(http.StatusNotFound)
  114. notFound.ExecuteTemplate(w, "404.html", nil)
  115. return
  116. }
  117. }
  118. // Return a 404 if the request is for a directory
  119. if info.IsDir() {
  120. files, err := ioutil.ReadDir(fp)
  121. if err != nil {
  122. log.Error().Err(err)
  123. }
  124. // Init template variables
  125. templateVars := templateVariables{}
  126. // Construct the breadcrumb
  127. path := strings.Split(cfp, "/")
  128. for len(path) > 1 {
  129. b := breadcrumb{
  130. Name: path[len(path)-1],
  131. Path: strings.Join(path, "/"),
  132. }
  133. path = path[:len(path)-1]
  134. templateVars.Path = append(templateVars.Path, b)
  135. }
  136. // Since the breadcrumb built is not very ordered...
  137. // REVERSE ALL THE THINGS
  138. for left, right := 0, len(templateVars.Path)-1; left < right; left, right = left+1, right-1 {
  139. templateVars.Path[left], templateVars.Path[right] = templateVars.Path[right], templateVars.Path[left]
  140. }
  141. // Establish list of files in the current directory
  142. for _, f := range files {
  143. templateVars.Files = append(templateVars.Files, fileItem{
  144. Name: f.Name(),
  145. Size: humanize.Bytes(uint64(f.Size())),
  146. Date: humanize.Time(f.ModTime()),
  147. IsDir: f.IsDir(),
  148. Path: filepath.Join(cfp, filepath.Clean(f.Name())),
  149. })
  150. }
  151. // Prepare the template
  152. tmpl, err := template.ParseFS(web.Index, "index.html")
  153. if err != nil {
  154. // Log the detailed error
  155. log.Error().Err(err)
  156. // Return a generic "Internal Server Error" message
  157. http.Error(w, http.StatusText(500), 500)
  158. return
  159. }
  160. // Return file listing in the template
  161. if err := tmpl.ExecuteTemplate(w, "index.html", templateVars); err != nil {
  162. log.Error().Err(err)
  163. http.Error(w, http.StatusText(500), 500)
  164. }
  165. return
  166. }
  167. if !info.IsDir() {
  168. content, _ := os.Open(fp)
  169. w.Header().Add("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", info.Name()))
  170. http.ServeContent(w, r, fp, info.ModTime(), content)
  171. return
  172. }
  173. }