edge/pkg/bundle/zim_bundle.go

448 lines
8.8 KiB
Go
Raw Normal View History

2023-07-11 02:42:05 +02:00
package bundle
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/net/html"
"forge.cadoles.com/arcad/edge/pkg/bundle/zim"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
"gopkg.in/yaml.v2"
)
type ZimBundle struct {
archivePath string
}
func (b *ZimBundle) File(filename string) (io.ReadCloser, os.FileInfo, error) {
ctx := logger.With(
context.Background(),
logger.F("filename", filename),
)
logger.Debug(ctx, "opening file")
switch filename {
case "manifest.yml":
return b.renderFakeManifest(ctx)
case "server/main.js":
return b.renderFakeServerMain(ctx)
case "public":
return b.renderDirectory(ctx, filename)
case "public/index.html":
return b.redirectToMainPage(ctx, filename)
default:
return b.renderURL(ctx, filename)
}
}
func (b *ZimBundle) Dir(dirname string) ([]os.FileInfo, error) {
reader, err := b.openArchive()
if err != nil {
return nil, err
}
defer func() {
if err := reader.Close(); err != nil {
panic(errors.WithStack(err))
}
}()
files := make([]os.FileInfo, 0)
// ctx := context.Background()
// for _, f := range reader.File {
// if !strings.HasPrefix(f.Name, dirname) {
// continue
// }
// relPath, err := filepath.Rel(dirname, f.Name)
// if err != nil {
// return nil, errors.Wrap(err, "could not get relative path")
// }
// logger.Debug(
// ctx, "checking file prefix",
// logger.F("dirname", dirname),
// logger.F("filename", f.Name),
// logger.F("relpath", relPath),
// )
// if relPath == filepath.Base(f.Name) {
// files = append(files, f.FileInfo())
// }
// }
return files, nil
}
func (b *ZimBundle) renderFakeManifest(ctx context.Context) (io.ReadCloser, os.FileInfo, error) {
reader, err := b.openArchive()
if err != nil {
return nil, nil, errors.WithStack(err)
}
defer func() {
if err := reader.Close(); err != nil {
panic(errors.WithStack(err))
}
}()
metadata, err := reader.Metadata()
if err != nil {
return nil, nil, errors.WithStack(err)
}
manifest := map[string]any{}
manifest["version"] = "0.0.0"
if name, exists := metadata[zim.MetadataName]; exists {
replacer := strings.NewReplacer(
"_", "",
" ", "",
)
manifest["id"] = strings.ToLower(replacer.Replace(name)) + ".zim.edge.app"
} else {
manifest["id"] = strconv.FormatUint(uint64(reader.UUID), 10) + ".zim.edge.app"
}
if title, exists := metadata[zim.MetadataTitle]; exists {
manifest["title"] = title
} else {
manifest["title"] = "Unknown"
}
if description, exists := metadata[zim.MetadataDescription]; exists {
manifest["description"] = description
}
favicon, err := reader.Favicon()
if err != nil && !errors.Is(err, zim.ErrNotFound) {
return nil, nil, errors.WithStack(err)
}
if favicon != nil {
manifestMeta, exists := manifest["metadata"].(map[string]any)
if !exists {
manifestMeta = make(map[string]any)
manifest["metadata"] = manifestMeta
}
paths, exists := manifestMeta["paths"].(map[string]any)
if !exists {
paths = make(map[string]any)
manifestMeta["paths"] = paths
}
paths["icon"] = "/" + favicon.FullURL()
}
data, err := yaml.Marshal(manifest)
if err != nil {
return nil, nil, errors.WithStack(err)
}
stat := &zimFileInfo{
isDir: false,
modTime: time.Time{},
mode: 0,
name: "manifest.yml",
size: int64(len(data)),
}
buf := bytes.NewBuffer(data)
file := ioutil.NopCloser(buf)
return file, stat, nil
}
func (b *ZimBundle) renderFakeServerMain(ctx context.Context) (io.ReadCloser, os.FileInfo, error) {
stat := &zimFileInfo{
isDir: false,
modTime: time.Time{},
mode: 0,
name: "server/main.js",
size: 0,
}
buf := bytes.NewBuffer(nil)
file := ioutil.NopCloser(buf)
return file, stat, nil
}
func (b *ZimBundle) renderURL(ctx context.Context, url string) (io.ReadCloser, os.FileInfo, error) {
zr, err := b.openArchive()
if err != nil {
return nil, nil, errors.WithStack(err)
}
defer func() {
if err := zr.Close(); err != nil {
panic(errors.WithStack(err))
}
}()
filename := filepath.Base(url)
url = strings.TrimPrefix(url, "public/")
article, err := zr.GetPageNoIndex(url)
if err != nil {
if errors.Is(err, zim.ErrNotFound) {
return nil, nil, errors.WithStack(fs.ErrNotExist)
}
return nil, nil, errors.WithStack(err)
}
if article.EntryType == zim.RedirectEntry {
redirectIndex, err := article.RedirectIndex()
if err != nil {
return nil, nil, errors.WithStack(err)
}
ra, err := zr.ArticleAtURLIdx(redirectIndex)
if err != nil {
return nil, nil, errors.WithStack(err)
}
return b.renderRedirect(ctx, filename, ra.FullURL())
}
data, err := article.Data()
if err != nil {
return nil, nil, errors.WithStack(err)
}
mimeType := article.MimeType()
if mimeType == "text/html" {
injected, err := b.injectEdgeScriptTag(data)
if err != nil {
logger.Error(ctx, "could not inject edge script", logger.E(errors.WithStack(err)))
} else {
data = injected
}
}
zimFile := &zimFile{
fileInfo: &zimFileInfo{
isDir: false,
modTime: time.Time{},
mode: 0,
name: filename,
size: int64(len(data)),
},
buff: bytes.NewBuffer(data),
}
return zimFile, zimFile.fileInfo, nil
}
func (b *ZimBundle) renderDirectory(ctx context.Context, filename string) (io.ReadCloser, os.FileInfo, error) {
zr, err := b.openArchive()
if err != nil {
return nil, nil, errors.WithStack(err)
}
defer func() {
if err := zr.Close(); err != nil {
panic(errors.WithStack(err))
}
}()
zimFile := &zimFile{
fileInfo: &zimFileInfo{
isDir: true,
modTime: time.Time{},
mode: 0,
name: filename,
size: 0,
},
buff: bytes.NewBuffer(nil),
}
return zimFile, zimFile.fileInfo, nil
}
func (b *ZimBundle) renderRedirect(ctx context.Context, filename string, to string) (io.ReadCloser, os.FileInfo, error) {
logger.Debug(ctx, "rendering redirect", logger.F("url", to))
data := fmt.Sprintf(`
<html>
<head>
<meta http-equiv="refresh" content="0; url=/%s" />
</head>
</html>
`, to)
stat := &zimFileInfo{
isDir: false,
modTime: time.Time{},
mode: 0,
name: filename,
size: int64(len(data)),
}
buf := bytes.NewBuffer([]byte(data))
reader := ioutil.NopCloser(buf)
return reader, stat, nil
}
func (b *ZimBundle) redirectToMainPage(ctx context.Context, filename string) (io.ReadCloser, os.FileInfo, error) {
zr, err := b.openArchive()
if err != nil {
return nil, nil, errors.WithStack(err)
}
defer func() {
if err := zr.Close(); err != nil {
panic(errors.WithStack(err))
}
}()
main, err := zr.MainPage()
if err != nil {
return nil, nil, errors.WithStack(err)
}
return b.renderRedirect(ctx, filename, main.FullURL())
}
func (b *ZimBundle) injectEdgeScriptTag(data []byte) ([]byte, error) {
buff := bytes.NewBuffer(data)
doc, err := html.Parse(buff)
if err != nil {
return nil, errors.WithStack(err)
}
var f func(*html.Node) bool
f = func(n *html.Node) bool {
if n.Type == html.ElementNode && n.Data == "head" {
script := &html.Node{
Type: html.ElementNode,
Data: "script",
Attr: []html.Attribute{
{
Key: "src",
Val: "/edge/sdk/client.js",
},
},
}
n.AppendChild(script)
return false
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if keepWalking := f(c); !keepWalking {
return false
}
}
return true
}
f(doc)
buff.Reset()
if err := html.Render(buff, doc); err != nil {
return nil, errors.WithStack(err)
}
return buff.Bytes(), nil
}
func (b *ZimBundle) openArchive() (*zim.ZimReader, error) {
zm, err := zim.NewReader(b.archivePath)
if err != nil {
return nil, errors.Wrapf(err, "could not open '%v'", b.archivePath)
}
return zm, nil
}
func NewZimBundle(archivePath string) *ZimBundle {
return &ZimBundle{
archivePath: archivePath,
}
}
type zimFile struct {
fileInfo *zimFileInfo
buff *bytes.Buffer
}
// Close implements fs.File.
func (f *zimFile) Close() error {
return nil
}
// Read implements fs.File.
func (f *zimFile) Read(d []byte) (int, error) {
return f.buff.Read(d)
}
// Stat implements fs.File.
func (f *zimFile) Stat() (fs.FileInfo, error) {
return f.fileInfo, nil
}
var _ fs.File = &zimFile{}
type zimFileInfo struct {
isDir bool
modTime time.Time
mode fs.FileMode
name string
size int64
}
// IsDir implements fs.FileInfo.
func (i *zimFileInfo) IsDir() bool {
return i.isDir
}
// ModTime implements fs.FileInfo.
func (i *zimFileInfo) ModTime() time.Time {
return i.modTime
}
// Mode implements fs.FileInfo.
func (i *zimFileInfo) Mode() fs.FileMode {
return i.mode
}
// Name implements fs.FileInfo.
func (i *zimFileInfo) Name() string {
return i.name
}
// Size implements fs.FileInfo.
func (i *zimFileInfo) Size() int64 {
return i.size
}
// Sys implements fs.FileInfo.
func (*zimFileInfo) Sys() any {
return nil
}
var _ fs.FileInfo = &zimFileInfo{}