mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-07 05:34:17 +00:00
feat: add fingerprinted asset generator
This commit is contained in:
parent
019603d55f
commit
05ece0bd8e
2 changed files with 279 additions and 0 deletions
114
web/assetsgen/generator.go
Normal file
114
web/assetsgen/generator.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
package assetsgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manifest map[string]string
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
SourceDir string
|
||||||
|
OutputDir string
|
||||||
|
HashLen int
|
||||||
|
}
|
||||||
|
|
||||||
|
func Generate(opts Options) (Manifest, error) {
|
||||||
|
if opts.HashLen <= 0 {
|
||||||
|
opts.HashLen = 8
|
||||||
|
}
|
||||||
|
if opts.HashLen > sha256.Size*2 {
|
||||||
|
opts.HashLen = sha256.Size * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := make(Manifest)
|
||||||
|
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := filepath.WalkDir(opts.SourceDir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(opts.SourceDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(raw)
|
||||||
|
hash := hex.EncodeToString(sum[:])[:opts.HashLen]
|
||||||
|
target := fingerprint(rel, hash)
|
||||||
|
targetPath := filepath.Join(opts.OutputDir, filepath.FromSlash(target))
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(targetPath, raw, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest[rel] = target
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteManifest(path string, manifest Manifest) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw = append(raw, '\n')
|
||||||
|
return os.WriteFile(path, raw, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprint(rel, hash string) string {
|
||||||
|
name := filepath.Base(rel)
|
||||||
|
if strings.HasPrefix(name, ".") && strings.Count(name, ".") == 1 {
|
||||||
|
return rel + "." + hash
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(rel)
|
||||||
|
base := strings.TrimSuffix(rel, ext)
|
||||||
|
if ext == "" {
|
||||||
|
return rel + "." + hash
|
||||||
|
}
|
||||||
|
return base + "." + hash + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyFile(dst string, src io.Reader) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = io.Copy(f, src)
|
||||||
|
return err
|
||||||
|
}
|
||||||
165
web/assetsgen/generator_test.go
Normal file
165
web/assetsgen/generator_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
package assetsgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateProducesFingerprintManifestAndFiles(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
dst := t.TempDir()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Join(src, "js"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "js", "app.js"), []byte("console.log('v1')\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := Generate(Options{
|
||||||
|
SourceDir: src,
|
||||||
|
OutputDir: filepath.Join(dst, "assets"),
|
||||||
|
HashLen: 8,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := manifest["js/app.js"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("manifest missing logical path: %#v", manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte("console.log('v1')\n"))
|
||||||
|
wantHash := hex.EncodeToString(sum[:])[:8]
|
||||||
|
want := "js/app." + wantHash + ".js"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("unexpected hashed filename: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(dst, "assets", got)); err != nil {
|
||||||
|
t.Fatalf("hashed output missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultManifest, err := Generate(Options{
|
||||||
|
SourceDir: src,
|
||||||
|
OutputDir: filepath.Join(dst, "default-assets"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate with default hash length returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotDefault := defaultManifest["js/app.js"]; gotDefault != want {
|
||||||
|
t.Fatalf("default HashLen mismatch: got %q want %q", gotDefault, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(dst, "default-assets", want)); err != nil {
|
||||||
|
t.Fatalf("default hashed output missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateClampsHashLenToSha256HexLength(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
dst := t.TempDir()
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "main.css"), []byte("body{}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := Generate(Options{
|
||||||
|
SourceDir: src,
|
||||||
|
OutputDir: filepath.Join(dst, "assets"),
|
||||||
|
HashLen: 65,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte("body{}\n"))
|
||||||
|
wantHash := hex.EncodeToString(sum[:])
|
||||||
|
want := "main." + wantHash + ".css"
|
||||||
|
if got := manifest["main.css"]; got != want {
|
||||||
|
t.Fatalf("unexpected hashed filename: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(dst, "assets", want)); err != nil {
|
||||||
|
t.Fatalf("clamped hashed output missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateFingerprintsDotfilesWithoutLeadingExtension(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
dst := t.TempDir()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Join(src, "dir"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, ".env"), []byte("ROOT=1\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "dir", ".env"), []byte("NESTED=1\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := Generate(Options{
|
||||||
|
SourceDir: src,
|
||||||
|
OutputDir: filepath.Join(dst, "assets"),
|
||||||
|
HashLen: 8,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootSum := sha256.Sum256([]byte("ROOT=1\n"))
|
||||||
|
rootWant := ".env." + hex.EncodeToString(rootSum[:])[:8]
|
||||||
|
if got := manifest[".env"]; got != rootWant {
|
||||||
|
t.Fatalf("unexpected root dotfile fingerprint: got %q want %q", got, rootWant)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dst, "assets", rootWant)); err != nil {
|
||||||
|
t.Fatalf("root dotfile output missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nestedSum := sha256.Sum256([]byte("NESTED=1\n"))
|
||||||
|
nestedWant := filepath.ToSlash(filepath.Join("dir", ".env.")) + hex.EncodeToString(nestedSum[:])[:8]
|
||||||
|
if got := manifest["dir/.env"]; got != nestedWant {
|
||||||
|
t.Fatalf("unexpected nested dotfile fingerprint: got %q want %q", got, nestedWant)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dst, "assets", filepath.FromSlash(nestedWant))); err != nil {
|
||||||
|
t.Fatalf("nested dotfile output missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteManifestSerializesStableJson(t *testing.T) {
|
||||||
|
dst := t.TempDir()
|
||||||
|
path := filepath.Join(dst, "assets-manifest.json")
|
||||||
|
manifest := Manifest{
|
||||||
|
"css/a.css": "css/a.11111111.css",
|
||||||
|
"js/b.js": "js/b.22222222.js",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := WriteManifest(path, manifest); err != nil {
|
||||||
|
t.Fatalf("WriteManifest returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "{\n \"css/a.css\": \"css/a.11111111.css\",\n \"js/b.js\": \"js/b.22222222.js\"\n}\n"
|
||||||
|
if string(raw) != want {
|
||||||
|
t.Fatalf("unexpected manifest json:\n got: %q\nwant: %q", string(raw), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]string
|
||||||
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||||
|
t.Fatalf("manifest json invalid: %v", err)
|
||||||
|
}
|
||||||
|
if decoded["js/b.js"] != "js/b.22222222.js" {
|
||||||
|
t.Fatalf("unexpected manifest entry: %#v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue