package web import ( "context" "encoding/json" "errors" "fmt" "github.com/thomiceli/opengist/internal/index" "github.com/thomiceli/opengist/internal/utils" "github.com/thomiceli/opengist/templates" htmlpkg "html" "html/template" "io" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/gorilla/sessions" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/markbates/goth/gothic" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" "github.com/thomiceli/opengist/internal/git" "github.com/thomiceli/opengist/internal/i18n" "github.com/thomiceli/opengist/public" "golang.org/x/text/language" ) var ( dev bool store *sessions.CookieStore re = regexp.MustCompile("[^a-z0-9]+") fm = template.FuncMap{ "split": strings.Split, "indexByte": strings.IndexByte, "toInt": func(i string) int { val, _ := strconv.Atoi(i) return val }, "inc": func(i int) int { return i + 1 }, "splitGit": func(i string) []string { return strings.FieldsFunc(i, func(r rune) bool { return r == ',' || r == ' ' }) }, "lines": func(i string) []string { return strings.Split(i, "\n") }, "isMarkdown": func(i string) bool { return strings.ToLower(filepath.Ext(i)) == ".md" }, "isCsv": func(i string) bool { return strings.ToLower(filepath.Ext(i)) == ".csv" }, "csvFile": func(file *git.File) *git.CsvFile { if strings.ToLower(filepath.Ext(file.Filename)) != ".csv" { return nil } csvFile, err := git.ParseCsv(file) if err != nil { return nil } return csvFile }, "httpStatusText": http.StatusText, "loadedTime": func(startTime time.Time) string { return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms" }, "slug": func(s string) string { return strings.Trim(re.ReplaceAllString(strings.ToLower(s), "-"), "-") }, "avatarUrl": func(user *db.User, noGravatar bool) string { if user.AvatarURL != "" { return user.AvatarURL } if user.MD5Hash != "" && !noGravatar { return "https://www.gravatar.com/avatar/" + user.MD5Hash + "?d=identicon&s=200" } return defaultAvatar() }, "asset": asset, "custom": customAsset, "dev": func() bool { return dev }, "defaultAvatar": defaultAvatar, "visibilityStr": func(visibility db.Visibility, lowercase bool) string { s := "Public" switch visibility { case 1: s = "Unlisted" case 2: s = "Private" } if lowercase { return strings.ToLower(s) } return s }, "unescape": htmlpkg.UnescapeString, "join": func(s ...string) string { return strings.Join(s, "") }, "toStr": func(i interface{}) string { return fmt.Sprint(i) }, "safe": func(s string) template.HTML { return template.HTML(s) }, "dict": func(values ...interface{}) (map[string]interface{}, error) { if len(values)%2 != 0 { return nil, errors.New("invalid dict call") } dict := make(map[string]interface{}) for i := 0; i < len(values); i += 2 { key, ok := values[i].(string) if !ok { return nil, errors.New("dict keys must be strings") } dict[key] = values[i+1] } return dict, nil }, "addMetadataToSearchQuery": addMetadataToSearchQuery, "indexEnabled": index.Enabled, "isUrl": func(s string) bool { _, err := url.ParseRequestURI(s) return err == nil }, } ) type Template struct { templates *template.Template } func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error { return t.templates.ExecuteTemplate(w, name, data) } type Server struct { echo *echo.Echo dev bool } func NewServer(isDev bool) *Server { dev = isDev store = sessions.NewCookieStore([]byte("opengist")) gothic.Store = store e := echo.New() e.HideBanner = true e.HidePort = true if err := i18n.Locales.LoadAll(); err != nil { log.Fatal().Err(err).Msg("Failed to load locales") } e.Use(dataInit) e.Use(locale) e.Pre(middleware.MethodOverrideWithConfig(middleware.MethodOverrideConfig{ Getter: middleware.MethodFromForm("_method"), })) e.Pre(middleware.RemoveTrailingSlash()) e.Pre(middleware.CORS()) e.Pre(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ LogURI: true, LogStatus: true, LogMethod: true, LogValuesFunc: func(ctx echo.Context, v middleware.RequestLoggerValues) error { log.Info().Str("URI", v.URI).Int("status", v.Status).Str("method", v.Method). Str("ip", ctx.RealIP()). Msg("HTTP") return nil }, })) e.Use(middleware.Recover()) e.Use(middleware.Secure()) t := template.Must(template.New("t").Funcs(fm).ParseFS(templates.Files, "*/*.html")) customPattern := filepath.Join(config.GetHomeDir(), "custom", "*.html") matches, err := filepath.Glob(customPattern) if err != nil { log.Fatal().Err(err).Msg("Failed to check for custom templates") } if len(matches) > 0 { t, err = t.ParseGlob(customPattern) if err != nil { log.Fatal().Err(err).Msg("Failed to parse custom templates") } } e.Renderer = &Template{ templates: t, } e.HTTPErrorHandler = func(er error, ctx echo.Context) { if err, ok := er.(*echo.HTTPError); ok { if err.Code >= 500 { log.Error().Int("code", err.Code).Err(err.Internal).Msg("HTTP: " + err.Message.(string)) } setData(ctx, "error", err) if errHtml := htmlWithCode(ctx, err.Code, "error.html"); errHtml != nil { log.Fatal().Err(errHtml).Send() } } else { log.Fatal().Err(er).Send() } } e.Use(sessionInit) e.Validator = utils.NewValidator() if !dev { parseManifestEntries() } // Web based routes g1 := e.Group("") { if !dev { g1.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{ TokenLookup: "form:_csrf", CookiePath: "/", CookieHTTPOnly: true, CookieSameSite: http.SameSiteStrictMode, })) g1.Use(csrfInit) } g1.GET("/", create, logged) g1.POST("/", processCreate, logged) g1.GET("/preview", preview, logged) g1.GET("/healthcheck", healthcheck) g1.GET("/register", register) g1.POST("/register", processRegister) g1.GET("/login", login) g1.POST("/login", processLogin) g1.GET("/logout", logout) g1.GET("/oauth/:provider", oauth) g1.GET("/oauth/:provider/callback", oauthCallback) g1.GET("/settings", userSettings, logged) g1.POST("/settings/email", emailProcess, logged) g1.DELETE("/settings/account", accountDeleteProcess, logged) g1.POST("/settings/ssh-keys", sshKeysProcess, logged) g1.DELETE("/settings/ssh-keys/:id", sshKeysDelete, logged) g1.PUT("/settings/password", passwordProcess, logged) g1.PUT("/settings/username", usernameProcess, logged) g2 := g1.Group("/admin-panel") { g2.Use(adminPermission) g2.GET("", adminIndex) g2.GET("/users", adminUsers) g2.POST("/users/:user/delete", adminUserDelete) g2.GET("/gists", adminGists) g2.POST("/gists/:gist/delete", adminGistDelete) g2.POST("/sync-fs", adminSyncReposFromFS) g2.POST("/sync-db", adminSyncReposFromDB) g2.POST("/gc-repos", adminGcRepos) g2.POST("/sync-previews", adminSyncGistPreviews) g2.POST("/reset-hooks", adminResetHooks) g2.POST("/index-gists", adminIndexGists) g2.GET("/configuration", adminConfig) g2.PUT("/set-config", adminSetConfig) } if config.C.HttpGit { e.Any("/init/*", gitHttp, gistNewPushSoftInit) } g1.GET("/all", allGists, checkRequireLogin) if index.Enabled() { g1.GET("/search", search, checkRequireLogin) } else { g1.GET("/search", allGists, checkRequireLogin) } g1.GET("/:user", allGists, checkRequireLogin) g1.GET("/:user/liked", allGists, checkRequireLogin) g1.GET("/:user/forked", allGists, checkRequireLogin) g3 := g1.Group("/:user/:gistname") { g3.Use(checkRequireLogin, gistInit) g3.GET("", gistIndex) g3.GET("/rev/:revision", gistIndex) g3.GET("/revisions", revisions) g3.GET("/archive/:revision", downloadZip) g3.POST("/visibility", toggleVisibility, logged, writePermission) g3.POST("/delete", deleteGist, logged, writePermission) g3.GET("/raw/:revision/:file", rawFile) g3.GET("/download/:revision/:file", downloadFile) g3.GET("/edit", edit, logged, writePermission) g3.POST("/edit", processCreate, logged, writePermission) g3.POST("/like", like, logged) g3.GET("/likes", likes) g3.POST("/fork", fork, logged) g3.GET("/forks", forks) g3.PUT("/checkbox", checkbox, logged, writePermission) } } customFs := os.DirFS(filepath.Join(config.GetHomeDir(), "custom")) e.GET("/assets/*", func(ctx echo.Context) error { if _, err := public.Files.Open(path.Join("assets", ctx.Param("*"))); !dev && err == nil { return echo.WrapHandler(http.FileServer(http.FS(public.Files)))(ctx) } // if the custom file is an .html template, render it if strings.HasSuffix(ctx.Param("*"), ".html") { if err := html(ctx, ctx.Param("*")); err != nil { return notFound("Page not found") } return nil } return echo.WrapHandler(http.StripPrefix("/assets/", http.FileServer(http.FS(customFs))))(ctx) }) // Git HTTP routes if config.C.HttpGit { e.Any("/:user/:gistname/*", gitHttp, gistSoftInit) } e.Any("/*", noRouteFound) return &Server{echo: e, dev: dev} } func (s *Server) Start() { addr := config.C.HttpHost + ":" + config.C.HttpPort log.Info().Msg("Starting HTTP server on http://" + addr) if err := s.echo.Start(addr); err != nil && err != http.ErrServerClosed { log.Fatal().Err(err).Msg("Failed to start HTTP server") } } func (s *Server) Stop() { if err := s.echo.Close(); err != nil { log.Fatal().Err(err).Msg("Failed to stop HTTP server") } } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.echo.ServeHTTP(w, r) } func dataInit(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { ctxValue := context.WithValue(ctx.Request().Context(), dataKey, echo.Map{}) ctx.SetRequest(ctx.Request().WithContext(ctxValue)) setData(ctx, "loadStartTime", time.Now()) if err := loadSettings(ctx); err != nil { return errorRes(500, "Cannot read settings from database", err) } setData(ctx, "c", config.C) setData(ctx, "githubOauth", config.C.GithubClientKey != "" && config.C.GithubSecret != "") setData(ctx, "gitlabOauth", config.C.GitlabClientKey != "" && config.C.GitlabSecret != "") setData(ctx, "giteaOauth", config.C.GiteaClientKey != "" && config.C.GiteaSecret != "") setData(ctx, "oidcOauth", config.C.OIDCClientKey != "" && config.C.OIDCSecret != "" && config.C.OIDCDiscoveryUrl != "") return next(ctx) } } func locale(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { // Check URL arguments lang := ctx.Request().URL.Query().Get("lang") changeLang := lang != "" // Then check cookies if len(lang) == 0 { cookie, _ := ctx.Request().Cookie("lang") if cookie != nil { lang = cookie.Value } } // Check again in case someone changes the supported language list. if lang != "" && !i18n.Locales.HasLocale(lang) { lang = "" changeLang = false } // 3.Then check from 'Accept-Language' header. if len(lang) == 0 { tags, _, _ := language.ParseAcceptLanguage(ctx.Request().Header.Get("Accept-Language")) lang = i18n.Locales.MatchTag(tags) } if changeLang { ctx.SetCookie(&http.Cookie{Name: "lang", Value: lang, Path: "/", MaxAge: 1<<31 - 1}) } localeUsed, err := i18n.Locales.GetLocale(lang) if err != nil { return errorRes(500, "Cannot get locale", err) } setData(ctx, "localeName", localeUsed.Name) setData(ctx, "locale", localeUsed) setData(ctx, "allLocales", i18n.Locales.Locales) return next(ctx) } } func sessionInit(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { sess := getSession(ctx) if sess.Values["user"] != nil { var err error var user *db.User if user, err = db.GetUserById(sess.Values["user"].(uint)); err != nil { sess.Values["user"] = nil saveSession(sess, ctx) setData(ctx, "userLogged", nil) return redirect(ctx, "/all") } if user != nil { setData(ctx, "userLogged", user) } return next(ctx) } setData(ctx, "userLogged", nil) return next(ctx) } } func csrfInit(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { setCsrfHtmlForm(ctx) return next(ctx) } } func writePermission(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { gist := getData(ctx, "gist") user := getUserLogged(ctx) if !gist.(*db.Gist).CanWrite(user) { return redirect(ctx, "/"+gist.(*db.Gist).User.Username+"/"+gist.(*db.Gist).Identifier()) } return next(ctx) } } func adminPermission(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { user := getUserLogged(ctx) if user == nil || !user.IsAdmin { return notFound("User not found") } return next(ctx) } } func logged(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { user := getUserLogged(ctx) if user != nil { return next(ctx) } return redirect(ctx, "/all") } } func checkRequireLogin(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { if user := getUserLogged(ctx); user != nil { return next(ctx) } require := getData(ctx, "RequireLogin") if require == true { addFlash(ctx, "You must be logged in to access gists", "error") return redirect(ctx, "/login") } return next(ctx) } } func noRouteFound(echo.Context) error { return notFound("Page not found") } // --- type Asset struct { File string `json:"file"` } var manifestEntries map[string]Asset func parseManifestEntries() { file, err := public.Files.Open("manifest.json") if err != nil { log.Fatal().Err(err).Msg("Failed to open manifest.json") } byteValue, err := io.ReadAll(file) if err != nil { log.Fatal().Err(err).Msg("Failed to read manifest.json") } if err = json.Unmarshal(byteValue, &manifestEntries); err != nil { log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json") } } func defaultAvatar() string { if dev { return "http://localhost:16157/default.png" } return config.C.ExternalUrl + "/" + manifestEntries["default.png"].File } func asset(file string) string { if dev { return "http://localhost:16157/" + file } return config.C.ExternalUrl + "/" + manifestEntries[file].File } func customAsset(file string) string { assetpath, err := url.JoinPath("/", "assets", file) if err != nil { log.Error().Err(err).Msgf("Failed to join path for custom file %s", file) } return config.C.ExternalUrl + assetpath }