github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/pkg/statik/statik.go (about)

     1  // Copyright 2014 Google Inc. All Rights Reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package contains a program that generates code to register
    16  // a directory and its contents as zip data for statik file system.
    17  package main
    18  
    19  import (
    20  	"bufio"
    21  	"bytes"
    22  	"crypto/sha256"
    23  	"encoding/hex"
    24  	"encoding/pem"
    25  	"errors"
    26  	"flag"
    27  	"fmt"
    28  	"io"
    29  	"net/http"
    30  	"os"
    31  	"path"
    32  	"path/filepath"
    33  	"sort"
    34  	"strconv"
    35  	"strings"
    36  
    37  	"github.com/andybalholm/brotli"
    38  	humanize "github.com/dustin/go-humanize"
    39  )
    40  
    41  const (
    42  	namePackage    = "statik"
    43  	nameSourceFile = "statik.go"
    44  )
    45  
    46  var (
    47  	flagSrc       = flag.String("src", path.Join(".", "public"), "The path of the source directory.")
    48  	flagDest      = flag.String("dest", ".", "The destination path of the generated package.")
    49  	flagExternals = flag.String("externals", "", "File containing a description of externals assets to download.")
    50  	flagForce     = flag.Bool("f", false, "Overwrite destination file if it already exists.")
    51  )
    52  
    53  var (
    54  	errExternalsMalformed = errors.New("assets externals file malformed")
    55  )
    56  
    57  type asset struct {
    58  	name   string
    59  	size   int64
    60  	url    string
    61  	data   []byte
    62  	sha256 []byte
    63  }
    64  
    65  func main() {
    66  	flag.Parse()
    67  
    68  	destDir := path.Join(*flagDest, namePackage)
    69  	destFilename := path.Join(destDir, nameSourceFile)
    70  
    71  	file, noChange, err := generateSource(destFilename, *flagSrc, *flagExternals)
    72  	if err != nil {
    73  		exitWithError(err)
    74  	}
    75  
    76  	if !noChange {
    77  		err = os.MkdirAll(destDir, 0755)
    78  		if err != nil {
    79  			exitWithError(err)
    80  		}
    81  
    82  		src := file.Name()
    83  
    84  		hSrc, err := shasum(src)
    85  		if err != nil {
    86  			exitWithError(err)
    87  		}
    88  		hDest, err := shasum(destFilename)
    89  		if err != nil {
    90  			exitWithError(err)
    91  		}
    92  
    93  		if !bytes.Equal(hSrc, hDest) {
    94  			err = rename(src, destFilename)
    95  			if err != nil {
    96  				exitWithError(err)
    97  			}
    98  			fmt.Println("asset file updated successfully")
    99  		} else {
   100  			fmt.Println("asset file left unchanged")
   101  		}
   102  	} else {
   103  		fmt.Println("asset file left unchanged")
   104  	}
   105  }
   106  
   107  func shasum(file string) ([]byte, error) {
   108  	h := sha256.New()
   109  	f, err := os.Open(file)
   110  	if err != nil {
   111  		return nil, err
   112  	}
   113  	defer f.Close()
   114  	if _, err := io.Copy(h, f); err != nil {
   115  		return nil, err
   116  	}
   117  	return h.Sum(nil), nil
   118  }
   119  
   120  // rename tries to os.Rename, but fall backs to copying from src
   121  // to dest and unlink the source if os.Rename fails.
   122  func rename(src, dest string) error {
   123  	// Try to rename generated source.
   124  	if err := os.Rename(src, dest); err == nil {
   125  		return nil
   126  	}
   127  	// If the rename failed (might do so due to temporary file residing on a
   128  	// different device), try to copy byte by byte.
   129  	rc, err := os.Open(src)
   130  	if err != nil {
   131  		return err
   132  	}
   133  	defer func() {
   134  		rc.Close()
   135  		os.Remove(src) // ignore the error, source is in tmp.
   136  	}()
   137  
   138  	if _, err = os.Stat(dest); !os.IsNotExist(err) {
   139  		if *flagForce {
   140  			if err = os.Remove(dest); err != nil {
   141  				return fmt.Errorf("file %q could not be deleted", dest)
   142  			}
   143  		} else {
   144  			return fmt.Errorf("file %q already exists; use -f to overwrite", dest)
   145  		}
   146  	}
   147  
   148  	wc, err := os.Create(dest)
   149  	if err != nil {
   150  		return err
   151  	}
   152  	defer wc.Close()
   153  
   154  	if _, err = io.Copy(wc, rc); err != nil {
   155  		// Delete remains of failed copy attempt.
   156  		os.Remove(dest)
   157  	}
   158  	return err
   159  }
   160  
   161  func loadAsset(name, srcPath string) (*asset, error) {
   162  	data := new(bytes.Buffer)
   163  
   164  	f, err := os.Open(name)
   165  	if err != nil {
   166  		return nil, err
   167  	}
   168  	defer f.Close()
   169  
   170  	h := sha256.New()
   171  	r := io.TeeReader(f, h)
   172  	size, err := io.Copy(data, r)
   173  	if err != nil {
   174  		return nil, err
   175  	}
   176  
   177  	relPath, err := filepath.Rel(srcPath, name)
   178  	if err != nil {
   179  		return nil, err
   180  	}
   181  
   182  	return &asset{
   183  		name:   path.Join("/", filepath.ToSlash(relPath)),
   184  		size:   size,
   185  		sha256: h.Sum(nil),
   186  		data:   data.Bytes(),
   187  	}, nil
   188  }
   189  
   190  // Walks on the source path and generates source code
   191  // that contains source directory's contents as zip contents.
   192  // Generates source registers generated zip contents data to
   193  // be read by the statik/fs HTTP file system.
   194  func generateSource(destFilename, srcPath, externalsFile string) (f *os.File, noChange bool, err error) {
   195  	var assets []*asset
   196  
   197  	currentAssets, err := readCurrentAssets(destFilename)
   198  	if err != nil {
   199  		return
   200  	}
   201  
   202  	doneCh := make(chan error)
   203  	filesCh := make(chan string)
   204  	assetsCh := make(chan *asset)
   205  
   206  	go func() {
   207  		defer close(filesCh)
   208  		err = filepath.Walk(srcPath, func(name string, fi os.FileInfo, err error) error {
   209  			if err != nil {
   210  				return err
   211  			}
   212  			// Ignore directories and hidden assets.
   213  			// No entry is needed for directories in a zip file.
   214  			// Each file is represented with a path, no directory
   215  			// entities are required to build the hierarchy.
   216  			if !fi.IsDir() && !strings.HasPrefix(fi.Name(), ".") {
   217  				filesCh <- name
   218  			}
   219  			return nil
   220  		})
   221  		if err != nil {
   222  			doneCh <- err
   223  		}
   224  	}()
   225  
   226  	for i := 0; i < 16; i++ {
   227  		go func() {
   228  			for name := range filesCh {
   229  				asset, err := loadAsset(name, srcPath)
   230  				if err != nil {
   231  					doneCh <- err
   232  					return
   233  				}
   234  				assetsCh <- asset
   235  			}
   236  			doneCh <- nil
   237  		}()
   238  	}
   239  
   240  	go func() {
   241  		defer close(assetsCh)
   242  		for i := 0; i < 16; i++ {
   243  			if err = <-doneCh; err != nil {
   244  				return
   245  			}
   246  		}
   247  	}()
   248  
   249  	for a := range assetsCh {
   250  		assets = append(assets, a)
   251  	}
   252  	if err != nil {
   253  		return
   254  	}
   255  
   256  	if externalsFile != "" {
   257  		var exts []*asset
   258  		exts, err = downloadExternals(externalsFile, currentAssets)
   259  		if err != nil {
   260  			return
   261  		}
   262  		assets = append(assets, exts...)
   263  	}
   264  
   265  	sort.Slice(assets, func(i, j int) bool {
   266  		return assets[i].name < assets[j].name
   267  	})
   268  
   269  	if len(assets) == len(currentAssets) {
   270  		noChange = true
   271  		for i, a := range assets {
   272  			old := currentAssets[i]
   273  			if old.name != a.name || !bytes.Equal(old.sha256, a.sha256) {
   274  				noChange = false
   275  				break
   276  			}
   277  		}
   278  	}
   279  	if noChange {
   280  		return
   281  	}
   282  
   283  	f, err = os.CreateTemp("", namePackage)
   284  	if err != nil {
   285  		return
   286  	}
   287  
   288  	_, err = fmt.Fprintf(f, `// Code generated by statik. DO NOT EDIT.
   289  
   290  package %s
   291  
   292  import (
   293  	fs "github.com/cozy/cozy-stack/pkg/assets/statik"
   294  )
   295  
   296  func init() {
   297  	data := `, namePackage)
   298  	if err != nil {
   299  		return
   300  	}
   301  
   302  	_, err = fmt.Fprint(f, "`")
   303  	if err != nil {
   304  		return
   305  	}
   306  
   307  	err = printData(f, assets)
   308  	if err != nil {
   309  		return
   310  	}
   311  
   312  	_, err = fmt.Fprint(f, "`")
   313  	if err != nil {
   314  		return
   315  	}
   316  	_, err = fmt.Fprint(f, `
   317  	fs.Register(data)
   318  }
   319  `)
   320  	if err != nil {
   321  		return
   322  	}
   323  
   324  	return
   325  }
   326  
   327  func downloadExternals(filename string, currentAssets []*asset) (newAssets []*asset, err error) {
   328  	externalAssets, err := parseExternalsFile(filename)
   329  	if err != nil {
   330  		return
   331  	}
   332  
   333  	currentAssetsMap := make(map[string]*asset)
   334  	for _, a := range currentAssets {
   335  		currentAssetsMap[a.name] = a
   336  	}
   337  
   338  	for _, externalAsset := range externalAssets {
   339  		var newAsset *asset
   340  		if a, ok := currentAssetsMap[externalAsset.name]; ok && bytes.Equal(a.sha256, externalAsset.sha256) {
   341  			newAsset = a
   342  		} else {
   343  			fmt.Fprintf(os.Stdout, "downloading %q... ", externalAsset.name)
   344  			newAsset, err = downloadExternal(externalAsset)
   345  			if err != nil {
   346  				return
   347  			}
   348  			fmt.Fprintf(os.Stdout, "ok (%s)\n", humanize.Bytes(uint64(newAsset.size)))
   349  		}
   350  		newAssets = append(newAssets, newAsset)
   351  	}
   352  
   353  	return
   354  }
   355  
   356  func parseExternalsFile(filename string) (assets []*asset, err error) {
   357  	f, err := os.Open(filename)
   358  	if err != nil {
   359  		return
   360  	}
   361  	defer func() {
   362  		if errc := f.Close(); errc != nil && err == nil {
   363  			err = errc
   364  		}
   365  	}()
   366  
   367  	var a *asset
   368  	scanner := bufio.NewScanner(f)
   369  	for scanner.Scan() {
   370  		line := scanner.Text()
   371  		if len(line) > 0 && line[0] == '#' {
   372  			continue
   373  		}
   374  		fields := strings.Fields(line)
   375  		switch len(fields) {
   376  		case 0:
   377  			if a != nil {
   378  				return nil, errExternalsMalformed
   379  			}
   380  		case 2:
   381  			if a == nil {
   382  				a = new(asset)
   383  			}
   384  			k, v := fields[0], fields[1]
   385  			switch strings.ToLower(k) {
   386  			case "name":
   387  				a.name = path.Join("/", v)
   388  			case "url":
   389  				a.url = v
   390  			case "sha256":
   391  				a.sha256, err = hex.DecodeString(v)
   392  				if err != nil {
   393  					return nil, errExternalsMalformed
   394  				}
   395  			}
   396  		default:
   397  			return nil, errExternalsMalformed
   398  		}
   399  		if a != nil && a.name != "" && a.url != "" && len(a.sha256) > 0 {
   400  			assets = append(assets, a)
   401  			a = nil
   402  		}
   403  	}
   404  	if errs := scanner.Err(); errs != nil {
   405  		return nil, errs
   406  	}
   407  
   408  	return
   409  }
   410  
   411  func downloadExternal(ext *asset) (f *asset, err error) {
   412  	res, err := http.Get(ext.url)
   413  	if err != nil {
   414  		return nil, err
   415  	}
   416  	defer res.Body.Close()
   417  
   418  	if res.StatusCode != http.StatusOK {
   419  		return nil, fmt.Errorf("could not fetch external assets %q: received status \"%d %s\"",
   420  			ext.url, res.StatusCode, res.Status)
   421  	}
   422  
   423  	h := sha256.New()
   424  	r := io.TeeReader(res.Body, h)
   425  
   426  	data, err := io.ReadAll(r)
   427  	if err != nil {
   428  		return nil, fmt.Errorf("could not fetch external asset: %s", err)
   429  	}
   430  
   431  	if sum := h.Sum(nil); !bytes.Equal(sum, ext.sha256) {
   432  		return nil, fmt.Errorf("shasum does not match: expected %x got %x",
   433  			ext.sha256, sum)
   434  	}
   435  
   436  	return &asset{
   437  		data:   data,
   438  		name:   ext.name,
   439  		size:   int64(len(data)),
   440  		sha256: ext.sha256,
   441  	}, nil
   442  }
   443  
   444  func readCurrentAssets(filename string) (assets []*asset, err error) {
   445  	statikFile, err := os.ReadFile(filename)
   446  	if err != nil && !os.IsNotExist(err) {
   447  		return nil, err
   448  	}
   449  
   450  	var zippedData []byte
   451  	if len(statikFile) > 0 {
   452  		i := bytes.Index(statikFile, []byte("`"))
   453  		if i >= 0 {
   454  			j := bytes.Index(statikFile[i+1:], []byte("`"))
   455  			if i >= 0 && j > i {
   456  				zippedData = statikFile[i+1 : i+j]
   457  			}
   458  		}
   459  	}
   460  
   461  	for {
   462  		block, rest := pem.Decode(zippedData)
   463  		if block == nil {
   464  			break
   465  		}
   466  		var size int64
   467  		size, err = strconv.ParseInt(block.Headers["Size"], 10, 64)
   468  		if err != nil {
   469  			return
   470  		}
   471  		br := brotli.NewReader(bytes.NewReader(block.Bytes))
   472  		var data []byte
   473  		h := sha256.New()
   474  		r := io.TeeReader(br, h)
   475  		data, err = io.ReadAll(r)
   476  		if err != nil {
   477  			return
   478  		}
   479  		name := block.Headers["Name"]
   480  		assets = append(assets, &asset{
   481  			name:   name,
   482  			size:   size,
   483  			data:   data,
   484  			sha256: h.Sum(nil),
   485  		})
   486  		zippedData = rest
   487  	}
   488  	return
   489  }
   490  
   491  // printData converts contents to a string literal.
   492  func printData(dest io.Writer, assets []*asset) error {
   493  	quality := brotli.BestCompression
   494  	if lvl := os.Getenv("BROTLI_LEVEL"); lvl != "" {
   495  		level, err := strconv.Atoi(lvl)
   496  		if err == nil {
   497  			quality = level
   498  		}
   499  	}
   500  	for _, f := range assets {
   501  		buf := new(bytes.Buffer)
   502  		bw := brotli.NewWriterLevel(buf, quality)
   503  		if _, err := io.Copy(bw, bytes.NewReader(f.data)); err != nil {
   504  			return err
   505  		}
   506  		if err := bw.Close(); err != nil {
   507  			return err
   508  		}
   509  		err := pem.Encode(dest, &pem.Block{
   510  			Type:  "COZY ASSET",
   511  			Bytes: buf.Bytes(),
   512  			Headers: map[string]string{
   513  				"Name": f.name,
   514  				"Size": strconv.FormatInt(f.size, 10),
   515  			},
   516  		})
   517  		if err != nil {
   518  			return err
   519  		}
   520  	}
   521  	return nil
   522  }
   523  
   524  // Prints out the error message and exists with a non-success signal.
   525  func exitWithError(err error) {
   526  	fmt.Println(err)
   527  	os.Exit(1)
   528  }