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  }