serve.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  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-dir", "d", "", "Directory containing data that Sakuin will serve")
  57. serveCmd.Flags().IntP("port", "p", 3000, "Port to listen to")
  58. serveCmd.Flags().String("listen-addr", "0.0.0.0", "Address to listen to")
  59. viper.BindPFlag("data-dir", serveCmd.Flags().Lookup("data-dir"))
  60. viper.BindPFlag("port", serveCmd.Flags().Lookup("port"))
  61. viper.BindPFlag("listen-addr", serveCmd.Flags().Lookup("listen-addr"))
  62. }
  63. func serve(cmd *cobra.Command, args []string) {
  64. dataDir = viper.GetString("data-dir")
  65. if dataDir == "" {
  66. log.Fatal().Err(errors.New("please specify a data directory, can't be empty"))
  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"))
  72. }
  73. }
  74. middleware := alice.New()
  75. // Install the logger handler with default output on the console
  76. middleware = middleware.Append(hlog.NewHandler(log))
  77. // Install some provided extra handler to set some request's context fields.
  78. // Thanks to that handler, all our logs will come with some prepopulated fields.
  79. middleware = middleware.Append(hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) {
  80. hlog.FromRequest(r).Info().
  81. Str("method", r.Method).
  82. Stringer("url", r.URL).
  83. Int("status", status).
  84. Int("size", size).
  85. Dur("duration", duration).
  86. Msg("")
  87. }))
  88. middleware = middleware.Append(hlog.RemoteAddrHandler("ip"))
  89. middleware = middleware.Append(hlog.UserAgentHandler("user_agent"))
  90. middleware = middleware.Append(hlog.RefererHandler("referer"))
  91. middleware = middleware.Append(hlog.RequestIDHandler("req_id", "Request-Id"))
  92. handler := middleware.Then(http.HandlerFunc(serverHandler))
  93. assetsHandler := middleware.Then(web.AssetsHandler("/assets/", "dist"))
  94. port := viper.GetInt("port")
  95. address := viper.GetString("listen-addr")
  96. mux := http.NewServeMux()
  97. mux.Handle("/assets/", assetsHandler)
  98. mux.Handle("/", handler)
  99. log.Info().Msgf("Starting Sakuin HTTP Server on %s:%d", address, port)
  100. http.ListenAndServe(fmt.Sprintf("%s:%d", address, port), mux)
  101. }
  102. func serverHandler(w http.ResponseWriter, r *http.Request) {
  103. // Filepath, from the root data dir
  104. fp := filepath.Join(dataDir, filepath.Clean(r.URL.Path))
  105. // Cleaned filepath, without the root data dir, used for template rendering purpose
  106. cfp := strings.Replace(fp, dataDir, "", 1)
  107. // Return a 404 if the template doesn't exist
  108. info, err := os.Stat(fp)
  109. if err != nil {
  110. if os.IsNotExist(err) {
  111. notFound, _ := template.ParseFS(web.NotFound, "404.html")
  112. w.WriteHeader(http.StatusNotFound)
  113. notFound.ExecuteTemplate(w, "404.html", nil)
  114. return
  115. }
  116. }
  117. // Return a 404 if the request is for a directory
  118. if info.IsDir() {
  119. files, err := ioutil.ReadDir(fp)
  120. if err != nil {
  121. log.Error().Err(err)
  122. }
  123. // Init template variables
  124. templateVars := templateVariables{}
  125. // Construct the breadcrumb
  126. path := strings.Split(cfp, "/")
  127. for len(path) > 1 {
  128. b := breadcrumb{
  129. Name: path[len(path)-1],
  130. Path: strings.Join(path, "/"),
  131. }
  132. path = path[:len(path)-1]
  133. templateVars.Path = append(templateVars.Path, b)
  134. }
  135. // Since the breadcrumb built is not very ordered...
  136. // REVERSE ALL THE THINGS
  137. for left, right := 0, len(templateVars.Path)-1; left < right; left, right = left+1, right-1 {
  138. templateVars.Path[left], templateVars.Path[right] = templateVars.Path[right], templateVars.Path[left]
  139. }
  140. // Establish list of files in the current directory
  141. for _, f := range files {
  142. templateVars.Files = append(templateVars.Files, fileItem{
  143. Name: f.Name(),
  144. Size: humanize.Bytes(uint64(f.Size())),
  145. Date: humanize.Time(f.ModTime()),
  146. IsDir: f.IsDir(),
  147. Path: filepath.Join(cfp, filepath.Clean(f.Name())),
  148. })
  149. }
  150. // Prepare the template
  151. tmpl, err := template.ParseFS(web.Index, "index.html")
  152. if err != nil {
  153. // Log the detailed error
  154. log.Error().Err(err)
  155. // Return a generic "Internal Server Error" message
  156. http.Error(w, http.StatusText(500), 500)
  157. return
  158. }
  159. // Return file listing in the template
  160. if err := tmpl.ExecuteTemplate(w, "index.html", templateVars); err != nil {
  161. log.Error().Err(err)
  162. http.Error(w, http.StatusText(500), 500)
  163. }
  164. return
  165. }
  166. if !info.IsDir() {
  167. content, _ := os.Open(fp)
  168. w.Header().Add("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", info.Name()))
  169. http.ServeContent(w, r, fp, info.ModTime(), content)
  170. return
  171. }
  172. }