github.com/Azareal/Gosora@v0.0.0-20210729070923-553e66b59003/common/theme.go (about) 1 /* Copyright Azareal 2016 - 2019 */ 2 package common 3 4 import ( 5 "bytes" 6 "crypto/sha256" 7 "database/sql" 8 "encoding/base64" 9 "encoding/hex" 10 "errors" 11 htmpl "html/template" 12 "io" 13 "io/ioutil" 14 "log" 15 "mime" 16 "net/http" 17 "os" 18 "path/filepath" 19 "reflect" 20 "strconv" 21 "strings" 22 "text/template" 23 24 p "github.com/Azareal/Gosora/common/phrases" 25 ) 26 27 var ErrNoDefaultTheme = errors.New("The default theme isn't registered in the system") 28 var ErrBadDefaultTemplate = errors.New("The template you tried to load doesn't exist in the interpreted pool.") 29 30 type Theme struct { 31 Path string // Redirect this file to another folder 32 33 Name string 34 FriendlyName string 35 Version string 36 Creator string 37 FullImage string 38 MobileFriendly bool 39 Disabled bool 40 HideFromThemes bool 41 BgAvatars bool // For profiles, at the moment 42 GridLists bool // User Manager 43 ForkOf string 44 Tag string 45 URL string 46 Docks []string // Allowed Values: leftSidebar, rightSidebar, footer 47 DocksID []int // Integer versions of Docks to try to get a speed boost in BuildWidget() 48 Settings map[string]ThemeSetting 49 IntTmplHandle *htmpl.Template 50 // TODO: Do we really need both OverridenTemplates AND OverridenMap? 51 OverridenTemplates []string 52 OverridenMap map[string]bool 53 Templates []TemplateMapping 54 TemplatesMap map[string]string 55 TmplPtr map[string]interface{} 56 Resources []ThemeResource 57 ResourceTemplates *template.Template 58 59 // Dock intercepters 60 // TODO: Implement this 61 MapTmplToDock map[string]ThemeMapTmplToDock // map[dockName]data 62 RunOnDock func(string) string //(dock string) (sbody string) 63 RunOnDockID func(int) string //(dock int) (sbody string) 64 65 // This variable should only be set and unset by the system, not the theme meta file 66 // TODO: Should we phase out Active and make the default theme store the primary source of truth? 67 Active bool 68 } 69 70 type ThemeSetting struct { 71 FriendlyName string 72 Options []string 73 } 74 75 type TemplateMapping struct { 76 Name string 77 Source string 78 //When string 79 } 80 81 const ( 82 ResTypeUnknown = iota 83 ResTypeSheet 84 ResTypeScript 85 ) 86 const ( 87 LocUnknown = iota 88 LocGlobal 89 LocFront 90 LocPanel 91 ) 92 93 type ThemeResource struct { 94 Name string 95 Type int // 0 = unknown, 1 = sheet, 2 = script 96 Location string 97 LocID int 98 Loggedin bool // Only serve this resource to logged in users 99 Async bool 100 } 101 102 type ThemeMapTmplToDock struct { 103 //Name string 104 File string 105 } 106 107 // TODO: It might be unsafe to call the template parsing functions with fsnotify, do something more concurrent 108 func (t *Theme) LoadStaticFiles() error { 109 t.ResourceTemplates = template.New("") 110 fmap := make(map[string]interface{}) 111 fmap["lang"] = func(phraseNameInt, tmplInt interface{}) interface{} { 112 phraseName, ok := phraseNameInt.(string) 113 if !ok { 114 panic("phraseNameInt is not a string") 115 } 116 tmpl, ok := tmplInt.(CSSData) 117 if !ok { 118 panic("tmplInt is not a CSSData") 119 } 120 phrase, ok := tmpl.Phrases[phraseName] 121 if !ok { 122 // TODO: XSS? Only server admins should have access to theme files anyway, but think about it 123 return "{lang." + phraseName + "}" 124 } 125 return phrase 126 } 127 fmap["toArr"] = func(args ...interface{}) []interface{} { 128 return args 129 } 130 fmap["concat"] = func(args ...interface{}) interface{} { 131 var out string 132 for _, arg := range args { 133 out += arg.(string) 134 } 135 return out 136 } 137 t.ResourceTemplates.Funcs(fmap) 138 // TODO: Minify these 139 //template.Must(t.ResourceTemplates.ParseGlob("./themes/" + t.Name + "/public/*.css")) 140 fnames, err := filepath.Glob("./themes/" + t.Name + "/public/*.css") 141 if err != nil { 142 return err 143 } 144 for _, fname := range fnames { 145 b, err := ioutil.ReadFile(fname) 146 if err != nil { 147 return err 148 } 149 //b := []byte("trolololol") 150 //b = bytes.ReplaceAll(b, []byte{10}, []byte("")) 151 //b = bytes.Replace(b, []byte("\n\n"), []byte(""), -1) 152 //b = bytes.Replace(b, []byte("}\n."), []byte("}."), -1) 153 //b = bytes.Replace(b, []byte("}\n:"), []byte("}:"), -1) 154 s := func() string { 155 s := string(b) 156 rep := func(from, to string) { 157 s = strings.Replace(s, from, to, -1) 158 } 159 rep("\r", "") 160 rep("}\n", "}") 161 rep("\n{", "{") 162 rep("\n", "") 163 rep(` 164 `, "") 165 rep(": {{", ":{{") 166 rep("display: ", "display:") 167 rep("float: ", "float:") 168 rep("-left: ", "-left:") 169 rep("-right: ", "-right:") 170 rep("-top: ", "-top:") 171 rep("-bottom: ", "-bottom:") 172 rep("border: ", "border:") 173 rep("radius: ", "radius:") 174 rep("content: ", "content:") 175 rep("width: ", "width:") 176 rep("padding: ", "padding:") 177 rep("-size: ", "-size:") 178 return s 179 }() 180 name := filepath.Base(fname) 181 t := t.ResourceTemplates 182 var tmpl *template.Template 183 /*if name == t.Name() { 184 tmpl = t 185 } else {*/ 186 tmpl = t.New(name) 187 //} 188 _, err = tmpl.Parse(s) 189 if err != nil { 190 return err 191 } 192 } 193 194 // It should be safe for us to load the files for all the themes in memory, as-long as the admin hasn't setup a ridiculous number of themes 195 return t.AddThemeStaticFiles() 196 } 197 198 func (t *Theme) AddThemeStaticFiles() error { 199 phraseMap := p.GetTmplPhrases() 200 // TODO: Use a function instead of a closure to make this more testable? What about a function call inside the closure to take the theme variable into account? 201 return filepath.Walk("./themes/"+t.Name+"/public", func(path string, f os.FileInfo, err error) error { 202 DebugLog("Attempting to add static file '" + path + "' for default theme '" + t.Name + "'") 203 if err != nil { 204 return err 205 } 206 if f.IsDir() { 207 return nil 208 } 209 210 path = strings.Replace(path, "\\", "/", -1) 211 data, err := ioutil.ReadFile(path) 212 if err != nil { 213 return err 214 } 215 216 ext := filepath.Ext(path) 217 if ext == ".js" { 218 data = bytes.Replace(data, []byte("\r"), []byte(""), -1) 219 } 220 if ext == ".css" && len(data) != 0 { 221 var b bytes.Buffer 222 pieces := strings.Split(path, "/") 223 filename := pieces[len(pieces)-1] 224 // TODO: Prepare resource templates for each loaded langpack? 225 err = t.ResourceTemplates.ExecuteTemplate(&b, filename, CSSData{Phrases: phraseMap}) 226 if err != nil { 227 log.Print("Failed in adding static file '" + path + "' for default theme '" + t.Name + "'") 228 return err 229 } 230 data = b.Bytes() 231 rep := func(from, to string) { 232 data = bytes.Replace(data, []byte(from), []byte(to), -1) 233 } 234 rep("\t", "") 235 //rep("\n\n", "") 236 rep("\n", "") 237 rep("\n", "") 238 rep(` 239 `, "") 240 rep("}\n.", "}.") 241 rep("}\n:", "}:") 242 rep(": #", ":#") 243 rep(" {", "{") 244 rep("{\n", "{") 245 rep(",\n", ",") 246 rep(";\n", ";") 247 rep(";\n}", ";}") 248 rep(": 0px;", ":0;") 249 rep("; }", ";}") 250 rep(", #", ",#") 251 } 252 253 path = strings.TrimPrefix(path, "themes/"+t.Name+"/public") 254 255 brData, err := CompressBytesBrotli(data) 256 if err != nil { 257 return err 258 } 259 // Don't use Brotli if we get meagre gains from it as it takes longer to process the responses 260 if len(brData) >= (len(data) + 130) { 261 brData = nil 262 } else { 263 diff := len(data) - len(brData) 264 if diff <= len(data)/100 { 265 brData = nil 266 } 267 } 268 269 gzipData, err := CompressBytesGzip(data) 270 if err != nil { 271 return err 272 } 273 // Don't use Gzip if we get meagre gains from it as it takes longer to process the responses 274 if len(gzipData) >= (len(data) + 150) { 275 gzipData = nil 276 } else { 277 diff := len(data) - len(gzipData) 278 if diff <= len(data)/100 { 279 gzipData = nil 280 } 281 } 282 283 // Get a checksum for CSPs and cache busting 284 hasher := sha256.New() 285 hasher.Write(data) 286 sum := hasher.Sum(nil) 287 checksum := hex.EncodeToString(sum) 288 integrity := base64.StdEncoding.EncodeToString(sum) 289 290 StaticFiles.Set(StaticFiles.Prefix+t.Name+path, &SFile{data, gzipData, brData, checksum, integrity, StaticFiles.Prefix + t.Name + path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)}) 291 292 DebugLog("Added the '/" + t.Name + path + "' static file for theme " + t.Name + ".") 293 return nil 294 }) 295 } 296 297 func (t *Theme) MapTemplates() { 298 if t.Templates != nil { 299 for _, themeTmpl := range t.Templates { 300 if themeTmpl.Name == "" { 301 LogError(errors.New("Invalid destination template name")) 302 } 303 if themeTmpl.Source == "" { 304 LogError(errors.New("Invalid source template name")) 305 } 306 307 // `go generate` is one possibility for letting plugins inject custom page structs, but it would simply add another step of compilation. It might be simpler than the current build process from the perspective of the administrator? 308 309 destTmplPtr, ok := TmplPtrMap[themeTmpl.Name] 310 if !ok { 311 return 312 } 313 sourceTmplPtr, ok := TmplPtrMap[themeTmpl.Source] 314 if !ok { 315 LogError(errors.New("The source template doesn't exist!")) 316 } 317 318 dTmplPtr, ok := destTmplPtr.(*func(interface{}, io.Writer) error) 319 if !ok { 320 log.Print("themeTmpl.Name: ", themeTmpl.Name) 321 log.Print("themeTmpl.Source: ", themeTmpl.Source) 322 LogError(errors.New("Unknown destination template type!")) 323 return 324 } 325 326 sTmplPtr, ok := sourceTmplPtr.(*func(interface{}, io.Writer) error) 327 if !ok { 328 LogError(errors.New("The source and destination templates are incompatible")) 329 return 330 } 331 332 overridenTemplates[themeTmpl.Name] = true 333 *dTmplPtr = *sTmplPtr 334 } 335 } 336 } 337 338 func (t *Theme) setActive(active bool) error { 339 var sink bool 340 err := themeStmts.isDefault.QueryRow(t.Name).Scan(&sink) 341 if err != nil && err != sql.ErrNoRows { 342 return err 343 } 344 345 hasTheme := err != sql.ErrNoRows 346 if hasTheme { 347 _, err = themeStmts.update.Exec(active, t.Name) 348 } else { 349 _, err = themeStmts.add.Exec(t.Name, active) 350 } 351 if err != nil { 352 return err 353 } 354 355 // TODO: Think about what we want to do for multi-server configurations 356 log.Printf("Setting theme '%s' as the default theme", t.Name) 357 t.Active = active 358 return nil 359 } 360 361 func UpdateDefaultTheme(t *Theme) error { 362 ChangeDefaultThemeMutex.Lock() 363 defer ChangeDefaultThemeMutex.Unlock() 364 365 err := t.setActive(true) 366 if err != nil { 367 return err 368 } 369 370 defaultTheme := DefaultThemeBox.Load().(string) 371 dtheme, ok := Themes[defaultTheme] 372 if !ok { 373 return ErrNoDefaultTheme 374 } 375 err = dtheme.setActive(false) 376 if err != nil { 377 return err 378 } 379 380 DefaultThemeBox.Store(t.Name) 381 ResetTemplateOverrides() 382 t.MapTemplates() 383 384 return nil 385 } 386 387 func (t Theme) HasDock(name string) bool { 388 for _, dock := range t.Docks { 389 if dock == name { 390 return true 391 } 392 } 393 return false 394 } 395 396 func (t Theme) BuildDock(dock string) (sbody string) { 397 runOnDock := t.RunOnDock 398 if runOnDock != nil { 399 return runOnDock(dock) 400 } 401 return "" 402 } 403 404 func (t Theme) HasDockByID(id int) bool { 405 for _, dock := range t.DocksID { 406 if dock == id { 407 return true 408 } 409 } 410 return false 411 } 412 413 func (t Theme) BuildDockByID(id int) (sbody string) { 414 runOnDock := t.RunOnDockID 415 if runOnDock != nil { 416 return runOnDock(id) 417 } 418 return "" 419 } 420 421 type GzipResponseWriter struct { 422 io.Writer 423 http.ResponseWriter 424 } 425 426 func (w GzipResponseWriter) Write(b []byte) (int, error) { 427 return w.Writer.Write(b) 428 } 429 430 // NEW method of doing theme templates to allow one user to have a different theme to another. Under construction. 431 // TODO: Generate the type switch instead of writing it by hand 432 // TODO: Cut the number of types in half 433 func (t *Theme) RunTmpl(template string, pi interface{}, w io.Writer) error { 434 // Unpack this to avoid an indirect call 435 if gzw, ok := w.(GzipResponseWriter); ok { 436 w = gzw.Writer 437 gzw.Header().Set("Content-Type", "text/html;charset=utf-8") 438 } 439 440 getTmpl := t.GetTmpl(template) 441 switch tmplO := getTmpl.(type) { 442 case *func(interface{}, io.Writer) error: 443 var tmpl = *tmplO 444 return tmpl(pi, w) 445 case func(interface{}, io.Writer) error: 446 return tmplO(pi, w) 447 case nil, string: 448 //fmt.Println("falling back to interpreted for " + template) 449 mapping, ok := t.TemplatesMap[template] 450 if !ok { 451 mapping = template 452 } 453 if t.IntTmplHandle.Lookup(mapping+".html") == nil { 454 return ErrBadDefaultTemplate 455 } 456 return t.IntTmplHandle.ExecuteTemplate(w, mapping+".html", pi) 457 default: 458 log.Print("theme ", t) 459 log.Print("template ", template) 460 log.Print("pi ", pi) 461 log.Print("tmplO ", tmplO) 462 log.Print("getTmpl ", getTmpl) 463 464 valueOf := reflect.ValueOf(tmplO) 465 log.Print("initial valueOf.Type()", valueOf.Type()) 466 for valueOf.Kind() == reflect.Interface || valueOf.Kind() == reflect.Ptr { 467 valueOf = valueOf.Elem() 468 log.Print("valueOf.Elem().Type() ", valueOf.Type()) 469 } 470 log.Print("deferenced valueOf.Type() ", valueOf.Type()) 471 log.Print("valueOf.Kind() ", valueOf.Kind()) 472 473 return errors.New("Unknown template type") 474 } 475 } 476 477 // GetTmpl attempts to get the template for a specific theme, otherwise it falls back on the default template pointer, which if absent will fallback onto the template interpreter 478 func (t *Theme) GetTmpl(template string) interface{} { 479 // TODO: Figure out why we're getting a nil pointer here when transpiled templates are disabled, I would have assumed that we would just fall back to !ok on this 480 // Might have something to do with it being the theme's TmplPtr map, investigate. 481 tmpl, ok := t.TmplPtr[template] 482 if ok { 483 return tmpl 484 } 485 tmpl, ok = TmplPtrMap[template+"_"+t.Name] 486 if ok { 487 return tmpl 488 } 489 tmpl, ok = TmplPtrMap[template] 490 if ok { 491 return tmpl 492 } 493 return template 494 }