| 1 | package server |
| 2 | |
| 3 | import ( |
| 4 | gojson "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | htmlpkg "html" |
| 8 | "html/template" |
| 9 | "io" |
| 10 | "io/fs" |
| 11 | "net/http" |
| 12 | "net/url" |
| 13 | "path/filepath" |
| 14 | "regexp" |
| 15 | "strconv" |
| 16 | "strings" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/dustin/go-humanize" |
| 20 | "github.com/labstack/echo/v4" |
| 21 | "github.com/rs/zerolog/log" |
| 22 | "github.com/thomiceli/opengist/internal/config" |
| 23 | "github.com/thomiceli/opengist/internal/db" |
| 24 | "github.com/thomiceli/opengist/internal/index" |
| 25 | "github.com/thomiceli/opengist/internal/web/context" |
| 26 | "github.com/thomiceli/opengist/internal/web/handlers" |
| 27 | "github.com/thomiceli/opengist/public" |
| 28 | "github.com/thomiceli/opengist/templates" |
| 29 | ) |
| 30 | |
| 31 | type Template struct { |
| 32 | templates *template.Template |
| 33 | // pages holds the new layout-based templates, keyed by file name (e.g. "all.html"). |
| 34 | // Each entry is a clone of the base layout with that page's "content" block parsed in. |
| 35 | pages map[string]*template.Template |
| 36 | } |
| 37 | |
| 38 | func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error { |
| 39 | if tmpl, ok := t.pages[name]; ok { |
| 40 | return tmpl.ExecuteTemplate(w, "base", data) |
| 41 | } |
| 42 | return t.templates.ExecuteTemplate(w, name, data) |
| 43 | } |
| 44 | |
| 45 | var re = regexp.MustCompile("[^a-z0-9]+") |
| 46 | |
| 47 | func (s *Server) setFuncMap() { |
| 48 | fm := template.FuncMap{ |
| 49 | "split": strings.Split, |
| 50 | "indexByte": strings.IndexByte, |
| 51 | "toInt": func(i string) int { |
| 52 | val, _ := strconv.Atoi(i) |
| 53 | return val |
| 54 | }, |
| 55 | "inc": func(i int) int { |
| 56 | return i + 1 |
| 57 | }, |
| 58 | "splitGit": func(i string) []string { |
| 59 | return strings.FieldsFunc(i, func(r rune) bool { |
| 60 | return r == ',' || r == ' ' |
| 61 | }) |
| 62 | }, |
| 63 | "lines": func(i string) []string { |
| 64 | return strings.Split(i, "\n") |
| 65 | }, |
| 66 | "isMarkdown": func(i string) bool { |
| 67 | return strings.ToLower(filepath.Ext(i)) == ".md" |
| 68 | }, |
| 69 | "isMermaid": func(i string) bool { |
| 70 | return strings.ToLower(filepath.Ext(i)) == ".mmd" |
| 71 | }, |
| 72 | "isJupyter": func(i string) bool { |
| 73 | return strings.ToLower(filepath.Ext(i)) == ".ipynb" |
| 74 | }, |
| 75 | "httpStatusText": http.StatusText, |
| 76 | "loadedTime": func(startTime time.Time) string { |
| 77 | return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms" |
| 78 | }, |
| 79 | "slug": func(s string) string { |
| 80 | return strings.Trim(re.ReplaceAllString(strings.ToLower(s), "-"), "-") |
| 81 | }, |
| 82 | "avatarUrl": func(user *db.User, noGravatar bool) string { |
| 83 | if user.HasUploadedAvatar() { |
| 84 | return fmt.Sprintf("%s/avatar/%s", config.C.ExternalUrl, user.AvatarURL) |
| 85 | } |
| 86 | |
| 87 | if user.AvatarURL != "" { |
| 88 | return user.AvatarURL |
| 89 | } |
| 90 | |
| 91 | if user.MD5Hash != "" && !noGravatar { |
| 92 | return "https://www.gravatar.com/avatar/" + user.MD5Hash + "?d=identicon&s=200" |
| 93 | } |
| 94 | |
| 95 | return "" |
| 96 | }, |
| 97 | "shouldGenerateAvatar": func(user *db.User, noGravatar bool) bool { |
| 98 | if user == nil { |
| 99 | return true |
| 100 | } |
| 101 | return user.AvatarURL == "" && (user.MD5Hash == "" || noGravatar) |
| 102 | }, |
| 103 | "asset": func(file string) string { |
| 104 | if s.dev { |
| 105 | return "http://localhost:16157/" + file |
| 106 | } |
| 107 | return config.C.ExternalUrl + "/" + context.ManifestEntries[file].File |
| 108 | }, |
| 109 | "assetCss": func(file string) string { |
| 110 | if s.dev { |
| 111 | return "http://localhost:16157/" + file |
| 112 | } |
| 113 | return config.C.ExternalUrl + "/" + context.ManifestEntries[file].Css[0] |
| 114 | }, |
| 115 | "custom": func(file string) string { |
| 116 | assetpath, err := url.JoinPath("/", "assets", file) |
| 117 | if err != nil { |
| 118 | log.Error().Err(err).Msgf("Failed to join path for custom file %s", file) |
| 119 | } |
| 120 | return config.C.ExternalUrl + assetpath |
| 121 | }, |
| 122 | "dev": func() bool { |
| 123 | return s.dev |
| 124 | }, |
| 125 | "visibilityStr": func(visibility db.Visibility, lowercase bool) string { |
| 126 | s := "Public" |
| 127 | switch visibility { |
| 128 | case 1: |
| 129 | s = "Unlisted" |
| 130 | case 2: |
| 131 | s = "Private" |
| 132 | } |
| 133 | |
| 134 | if lowercase { |
| 135 | return strings.ToLower(s) |
| 136 | } |
| 137 | return s |
| 138 | }, |
| 139 | "unescape": htmlpkg.UnescapeString, |
| 140 | "join": func(s ...string) string { |
| 141 | return strings.Join(s, "") |
| 142 | }, |
| 143 | "toStr": func(i interface{}) string { |
| 144 | return fmt.Sprint(i) |
| 145 | }, |
| 146 | "safe": func(s string) template.HTML { |
| 147 | return template.HTML(s) |
| 148 | }, |
| 149 | "dict": func(values ...interface{}) (map[string]interface{}, error) { |
| 150 | if len(values)%2 != 0 { |
| 151 | return nil, errors.New("invalid dict call") |
| 152 | } |
| 153 | dict := make(map[string]interface{}) |
| 154 | for i := 0; i < len(values); i += 2 { |
| 155 | key, ok := values[i].(string) |
| 156 | if !ok { |
| 157 | return nil, errors.New("dict keys must be strings") |
| 158 | } |
| 159 | dict[key] = values[i+1] |
| 160 | } |
| 161 | return dict, nil |
| 162 | }, |
| 163 | "addMetadataToSearchQuery": func(input, key, value string) string { |
| 164 | metadata := handlers.ParseSearchQueryStr(input) |
| 165 | // extract free-text content (stored under "all") and remove it from metadata |
| 166 | content := metadata["all"] |
| 167 | delete(metadata, "all") |
| 168 | |
| 169 | metadata[key] = value |
| 170 | |
| 171 | var resultBuilder strings.Builder |
| 172 | resultBuilder.WriteString(content) |
| 173 | |
| 174 | for k, v := range metadata { |
| 175 | resultBuilder.WriteString(" ") |
| 176 | resultBuilder.WriteString(k) |
| 177 | resultBuilder.WriteString(":") |
| 178 | resultBuilder.WriteString(v) |
| 179 | } |
| 180 | |
| 181 | return strings.TrimSpace(resultBuilder.String()) |
| 182 | }, |
| 183 | "indexEnabled": index.IndexEnabled, |
| 184 | "isUrl": func(s string) bool { |
| 185 | _, err := url.ParseRequestURI(s) |
| 186 | return err == nil |
| 187 | }, |
| 188 | "topicsToStr": func(topics []db.GistTopic) string { |
| 189 | str := "" |
| 190 | for i, topic := range topics { |
| 191 | if i > 0 { |
| 192 | str += " " |
| 193 | } |
| 194 | str += topic.Topic |
| 195 | } |
| 196 | return str |
| 197 | }, |
| 198 | "hexToRgb": func(hex string) string { |
| 199 | h, _ := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 32) |
| 200 | return fmt.Sprintf("%d, %d, %d,", (h>>16)&0xFF, (h>>8)&0xFF, h&0xFF) |
| 201 | }, |
| 202 | "humanTimeDiff": func(t int64) string { |
| 203 | return humanize.Time(time.Unix(t, 0)) |
| 204 | }, |
| 205 | "humanTimeDiffStr": func(timestamp string) string { |
| 206 | t, _ := strconv.ParseInt(timestamp, 10, 64) |
| 207 | return humanize.Time(time.Unix(t, 0)) |
| 208 | }, |
| 209 | "humanDate": func(t int64) string { |
| 210 | return time.Unix(t, 0).Format("02/01/2006 15:04") |
| 211 | }, |
| 212 | "humanDateOnly": func(t int64) string { |
| 213 | return time.Unix(t, 0).Format("02/01/2006") |
| 214 | }, |
| 215 | "mainTheme": func(theme *db.UserStyleDTO) string { |
| 216 | if theme == nil { |
| 217 | return "auto" |
| 218 | } |
| 219 | |
| 220 | if theme.Theme == "" { |
| 221 | return "auto" |
| 222 | } |
| 223 | |
| 224 | return theme.Theme |
| 225 | }, |
| 226 | } |
| 227 | |
| 228 | base := template.Must(template.New("base").Funcs(fm).ParseFS(templates.Files, "layouts/*.html", "partials/*.html")) |
| 229 | pagePaths, err := fs.Glob(templates.Files, "pages/*.html") |
| 230 | if err != nil { |
| 231 | log.Fatal().Err(err).Msg("Failed to glob new page templates") |
| 232 | } |
| 233 | pages := make(map[string]*template.Template, len(pagePaths)) |
| 234 | for _, p := range pagePaths { |
| 235 | cloned := template.Must(base.Clone()) |
| 236 | pages[filepath.Base(p)] = template.Must(cloned.ParseFS(templates.Files, p)) |
| 237 | } |
| 238 | |
| 239 | s.echo.Renderer = &Template{ |
| 240 | templates: base, |
| 241 | pages: pages, |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | func (s *Server) parseManifestEntries() { |
| 246 | file, err := public.Files.Open(".vite/manifest.json") |
| 247 | if err != nil { |
| 248 | log.Fatal().Err(err).Msg("Failed to open manifest.json") |
| 249 | } |
| 250 | byteValue, err := io.ReadAll(file) |
| 251 | if err != nil { |
| 252 | log.Fatal().Err(err).Msg("Failed to read manifest.json") |
| 253 | } |
| 254 | if err = gojson.Unmarshal(byteValue, &context.ManifestEntries); err != nil { |
| 255 | log.Fatal().Err(err).Msg("Failed to unmarshal manifest.json") |
| 256 | } |
| 257 | } |
| 258 |
t
thomas / gistfile1.txt
Last active 5 hours ago
Revision d18a7cc77750fb755b7bbe9ddc935b20152e5088