Skip to content
Snippets Groups Projects
Select Git revision
  • 4671392d90e98ea4edf6e9ce7023d21cc9957d8c
  • wip-bootstrap default
  • dualcore
  • ch3/leds
  • ch3/time
  • master
6 results

builtintables.c

Blame
  • server_apps.go 16.65 KiB
    package main
    
    import (
    	"archive/tar"
    	"archive/zip"
    	"bytes"
    	"compress/gzip"
    	"context"
    	"crypto/md5"
    	"encoding/json"
    	"fmt"
    	"io"
    	"log"
    	"net/http"
    	"net/url"
    	"path"
    	"regexp"
    	"strings"
    	"time"
    
    	"github.com/go-git/go-git/v5"
    	"github.com/go-git/go-git/v5/config"
    	"github.com/go-git/go-git/v5/plumbing"
    	"github.com/go-git/go-git/v5/plumbing/object"
    	"github.com/go-git/go-git/v5/storage/memory"
    	"github.com/pelletier/go-toml/v2"
    )
    
    type appRegistry struct {
    	shame []*appShame
    	apps  []*appDescriptor
    }
    
    type appShame struct {
    	repository string
    	errorMsg   string
    }
    
    type appDescriptor struct {
    	repository string
    	pathInRepo string
    	appInfo    *appInfo
    }
    
    type appInfo struct {
    	name        string
    	menu        string
    	author      string
    	description string
    	version     int
    	commit      string
    	stars       int
    	flow3rSeed  string
    
    	commitObj *object.Commit
    	zip       []byte
    	targz     []byte
    
    	firstErr error
    }
    
    type GLProject struct {
    	StarCount int `json:"star_count"`
    }
    
    var (
    	reAppPath = regexp.MustCompile(`^apps/([^/]+.toml)$`)
    	reAppRepo = regexp.MustCompile(`^([a-zA-Z\-_\.0-9]+)/([a-zA-Z\-_0-9]+)$`)
    )
    
    func (s *server) getStars(ctx context.Context, repo string) (int, error) {
    	path := fmt.Sprintf("https://%s/api/v4/projects/%s", flagGitlabHost, url.PathEscape(repo))
    
    	req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
    	if err != nil {
    		return 0, fmt.Errorf("when building request: %w", err)
    	}
    	res, err := http.DefaultTransport.RoundTrip(req)
    	if err != nil {
    		return 0, fmt.Errorf("when performing request: %w", err)
    	}
    	defer res.Body.Close()
    
    	if res.StatusCode != 200 {
    		return 0, fmt.Errorf("invalid response code %d", res.StatusCode)
    	}
    
    	var project GLProject
    	j := json.NewDecoder(res.Body)
    	err = j.Decode(&project)
    	if err != nil {
    		return 0, fmt.Errorf("when performing request: %w", err)
    	}
    	return project.StarCount, nil
    }
    
    func (s *server) zipApp(ctx context.Context, name, pathInRepo, repo string, obj *object.Commit) ([]byte, error) {
    	fi, err := obj.Files()
    	if err != nil {
    		return nil, fmt.Errorf("listing files: %w", err)
    	}
    	buf := bytes.NewBuffer(nil)
    	w := zip.NewWriter(buf)
    	for {
    		f, err := fi.Next()
    		if err == io.EOF {
    			break
    		}
    		if err != nil {
    			return nil, fmt.Errorf("fi.Next: %w", err)
    		}
    		prefix := ""
    		if pathInRepo != "" {
    			prefix = pathInRepo + "/"
    		}
    		if !strings.HasPrefix(f.Name, prefix) {
    			continue
    		}
    		if !f.Mode.IsFile() {
    			continue
    		}
    		p := f.Name[len(prefix):]
    		prefix = strings.ReplaceAll(repo, "/", "-")
    		outPath := path.Join(prefix, p)
    		fo, err := w.Create(outPath)
    		if err != nil {
    			return nil, fmt.Errorf("Create(%q): %w", outPath, err)
    		}
    		if f.Size+int64(buf.Len()) > 10<<20 {
    			return nil, fmt.Errorf("archive too large")
    		}
    		rdr, err := f.Blob.Reader()
    		if err != nil {
    			return nil, fmt.Errorf("Blob.Reader: %w", err)
    		}
    		_, err = io.Copy(fo, rdr)
    		rdr.Close()
    		if err != nil {
    			return nil, fmt.Errorf("when copying: %w", err)
    		}
    	}
    	if err := w.Close(); err != nil {
    		return nil, fmt.Errorf("when closing: %w", err)
    	}
    	return buf.Bytes(), nil
    }
    
    func (s *server) targzApp(ctx context.Context, name, pathInRepo, repo string, obj *object.Commit) ([]byte, error) {
    	fi, err := obj.Files()
    	if err != nil {
    		return nil, fmt.Errorf("listing files: %w", err)
    	}
    	buf := bytes.NewBuffer(nil)
    	gz := gzip.NewWriter(buf)
    	t := tar.NewWriter(gz)
    
    	directories := make(map[string]bool)
    	for {
    		f, err := fi.Next()
    		if err == io.EOF {
    			break
    		}
    		if err != nil {
    			return nil, fmt.Errorf("fi.Next: %w", err)
    		}
    		prefix := ""
    		if pathInRepo != "" {
    			prefix = pathInRepo + "/"
    		}
    		if !strings.HasPrefix(f.Name, prefix) {
    			continue
    		}
    		if !f.Mode.IsFile() {
    			continue
    		}
    		p := f.Name[len(prefix):]
    		prefix = strings.ReplaceAll(repo, "/", "-")
    		outPath := path.Join(prefix, p)
    
    		ensureDir := func(p string) error {
    			if directories[p] {
    				return nil
    			}
    			directories[p] = true
    			err = t.WriteHeader(&tar.Header{
    				Name:     p,
    				Typeflag: tar.TypeDir,
    				Mode:     0755,
    			})
    			if err != nil {
    				return fmt.Errorf("CreateDir(%q): %w", p, err)
    			}
    			return nil
    		}
    
    		dirs, _ := path.Split(outPath)
    		if dirs != "" {
    			dirs = strings.TrimRight(dirs, "/")
    			parts := strings.Split(dirs, "/")
    			cur := parts[0] + "/"
    
    			if err := ensureDir(cur); err != nil {
    				return nil, err
    			}
    			for i := 1; i < len(parts); i++ {
    				cur += parts[i] + "/"
    				if err := ensureDir(cur); err != nil {
    					return nil, err
    				}
    			}
    		}
    		err = t.WriteHeader(&tar.Header{
    			Name:     outPath,
    			Typeflag: tar.TypeReg,
    			Size:     f.Size,
    			Mode:     0644,
    		})
    		if err != nil {
    			return nil, fmt.Errorf("Create(%q): %w", outPath, err)
    		}
    		if f.Size+int64(buf.Len()) > 10<<20 {
    			return nil, fmt.Errorf("archive too large")
    		}
    		rdr, err := f.Blob.Reader()
    		if err != nil {
    			return nil, fmt.Errorf("Blob.Reader: %w", err)
    		}
    		_, err = io.Copy(t, rdr)
    		rdr.Close()
    		if err != nil {
    			return nil, fmt.Errorf("when copying: %w", err)
    		}
    	}
    	if err := t.Close(); err != nil {
    		return nil, fmt.Errorf("when closing tar: %w", err)
    	}
    	if err := gz.Close(); err != nil {
    		return nil, fmt.Errorf("when closing gz: %w", err)
    	}
    	return buf.Bytes(), nil
    }
    
    func (s *server) parseAppToml(ctx context.Context, pathInRepo string, obj *object.Commit) (*appInfo, error) {
    	p := path.Join(pathInRepo, "flow3r.toml")
    	f, err := obj.File(p)
    	if err != nil {
    		return nil, fmt.Errorf("toml open (%q) failed: %w", p, err)
    	}
    	reader, err := f.Reader()
    	if err != nil {
    		return nil, fmt.Errorf("f.Reader: %w", err)
    	}
    	defer reader.Close()
    	var data struct {
    		App struct {
    			Name string
    			Menu string
    		}
    		Entry struct {
    			Class string
    		}
    		Metadata struct {
    			Author      string
    			License     string
    			URL         string
    			Description string
    			Version     int
    		}
    	}
    	dec := toml.NewDecoder(reader)
    	err = dec.Decode(&data)
    	if err != nil {
    		return nil, fmt.Errorf("toml decode (%q) failed: %w", p, err)
    	}
    	if data.App.Name == "" || len(data.App.Name) > 32 {
    		return nil, fmt.Errorf("app name invalid (must be non-empty and <= 32 chars)")
    	}
    	sections := map[string]bool{
    		"Badge": true,
    		"Apps":  true,
    		"Music": true,
    	}
    	if !sections[data.App.Menu] {
    		return nil, fmt.Errorf("app menu invalid (must be one of 'Badge', 'Apps', 'Music')")
    	}
    	if data.Entry.Class == "" {
    		return nil, fmt.Errorf("no entry class")
    	}
    	if len(data.Metadata.Author) > 32 {
    		return nil, fmt.Errorf("metadata author too long (must be <= 32 chars)")
    	}
    	if len(data.Metadata.Description) > 140 {
    		return nil, fmt.Errorf("metadata description too long (must be <= 140 chars)")
    	}
    
    	return &appInfo{
    		name:        data.App.Name,
    		author:      data.Metadata.Author,
    		menu:        data.App.Menu,
    		description: data.Metadata.Description,
    		version:     data.Metadata.Version,
    		commit:      obj.Hash.String(),
    		commitObj:   obj,
    	}, nil
    }
    
    func (s *server) getAppInfo(ctx context.Context, pathInRepo, repo string) (*appInfo, error) {
    	url := "https://git.flow3r.garden/" + repo
    	g, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
    		URL: url,
    	})
    	if err != nil {
    		return nil, fmt.Errorf("when cloning: %w", err)
    	}
    
    	cfg, err := g.Config()
    	if err != nil {
    		return nil, fmt.Errorf("when getting config: %w", err)
    	}
    	if len(cfg.Branches) < 1 {
    		return nil, fmt.Errorf("no branches")
    	}
    	var bname string
    	for name := range cfg.Branches {
    		bname = name
    		break
    	}
    	h, err := g.ResolveRevision(plumbing.Revision(bname))
    	if err != nil {
    		return nil, fmt.Errorf("resolving revision failed: %w", err)
    	}
    	err = g.Fetch(&git.FetchOptions{
    		RefSpecs: []config.RefSpec{
    			config.RefSpec(fmt.Sprintf("%s:refs/heads/%s", bname, bname)),
    		},
    	})
    	if err != nil && err != git.NoErrAlreadyUpToDate {
    		return nil, fmt.Errorf("fetch failed: %w", err)
    	}
    	obj, err := g.CommitObject(*h)
    	if err != nil {
    		return nil, fmt.Errorf("CommitObject(%v): %w", h, err)
    	}
    
    	highestVer := 0
    	highsetVerNil := true
    	firstTime := make(map[int]*appInfo)
    	var firstErr error = nil
    	for {
    		info, err := s.parseAppToml(ctx, pathInRepo, obj)
    		if err == nil {
    			ver := info.version
    			firstTime[ver] = info
    			if ver > highestVer || highsetVerNil {
    				highestVer = ver
    				highsetVerNil = false
    			}
    		} else {
    			if highsetVerNil && firstErr == nil {
    				firstErr = err
    				log.Printf("%s@%s: latest error: %v", repo, obj.Hash.String(), err)
    			} else {
    				log.Printf("%s@%s: %v", repo, obj.Hash.String(), err)
    			}
    		}
    		if len(obj.ParentHashes) == 0 {
    			break
    		}
    		obj, err = g.CommitObject(obj.ParentHashes[0])
    		if err != nil {
    			return nil, fmt.Errorf("CommitObject(%v): %w", h, err)
    		}
    	}
    
    	if highsetVerNil {
    		if firstErr != nil {
    			return nil, firstErr
    		}
    		return nil, fmt.Errorf("no `version` field in `flow3r.toml`")
    	}
    	stars, err := s.getStars(ctx, repo)
    	if err != nil {
    		return nil, fmt.Errorf("getting gitlab stars failed: %w", err)
    	}
    	app := firstTime[highestVer]
    	app.stars = stars
    	zbytes, err := s.zipApp(ctx, app.name, pathInRepo, repo, app.commitObj)
    	if err != nil {
    		return nil, fmt.Errorf("zipping failed: %w", err)
    	}
    	app.zip = zbytes
    	tbytes, err := s.targzApp(ctx, app.name, pathInRepo, repo, app.commitObj)
    	if err != nil {
    		return nil, fmt.Errorf("targzing failed: %w", err)
    	}
    	app.targz = tbytes
    
    	// Calculate an 8 digit flow3r seed which can be used to install the
    	// app.  This is based on md5 so ambitious hack3rs can force a certain
    	// app seed :)
    	flow3rSeedMd5 := md5.Sum([]byte(repo))
    	app.flow3rSeed = ""
    	for i := 0; i < 8; i++ {
    		app.flow3rSeed += string(flow3rSeedMd5[i]%5 + byte('0'))
    	}
    
    	app.firstErr = firstErr
    
    	return app, nil
    }
    
    func (s *server) getAppRegistry(ctx context.Context) (*appRegistry, error) {
    	s.gitMu.Lock()
    	defer s.gitMu.Unlock()
    	if s.appRepo == nil {
    		git, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
    			URL: "https://git.flow3r.garden/flow3r/flow3r-apps",
    		})
    		if err != nil {
    			return nil, err
    		}
    		s.appRepo = git
    	}
    
    	if time.Since(s.lastFetch) > 10*time.Second {
    		s.appRepo.Fetch(&git.FetchOptions{
    			RefSpecs: []config.RefSpec{
    				config.RefSpec("+refs/heads/*:refs/heads/*"),
    			},
    			Force: true,
    		})
    	}
    
    	h, err := s.appRepo.ResolveRevision(plumbing.Revision("main"))
    	if err != nil {
    		return nil, err
    	}
    	obj, err := s.appRepo.CommitObject(*h)
    	if err != nil {
    		return nil, err
    	}
    	iter, err := obj.Files()
    	if err != nil {
    		return nil, err
    	}
    
    	var registry appRegistry
    	for {
    		f, err := iter.Next()
    		if err == io.EOF {
    			break
    		}
    		if err != nil {
    			return nil, err
    		}
    		if !f.Mode.IsFile() {
    			continue
    		}
    		m := reAppPath.FindStringSubmatch(f.Name)
    		if m == nil {
    			continue
    		}
    		reader, err := f.Reader()
    		if err != nil {
    			log.Printf("App %q: couldn't read TOML: %v", m[1], err)
    			registry.shame = append(registry.shame, &appShame{
    				repository: fmt.Sprintf("<app-list toml: %q>", m[1]),
    				errorMsg:   fmt.Sprintf("couldn't read TOML: %v", err),
    			})
    			continue
    		}
    
    		dec := toml.NewDecoder(reader)
    		var dat struct {
    			Repo       string
    			PathInRepo string `toml:"path_in_repo"`
    		}
    		err = dec.Decode(&dat)
    		reader.Close()
    		if err != nil {
    			log.Printf("App %q: couldn't parse TOML: %v", m[1], err)
    			registry.shame = append(registry.shame, &appShame{
    				repository: fmt.Sprintf("<app-list toml: %q>", m[1]),
    				errorMsg:   fmt.Sprintf("couldn't read TOML: %v", err),
    			})
    			continue
    		}
    		n := reAppRepo.FindStringSubmatch(dat.Repo)
    		if n == nil {
    			log.Printf("App %q: invalid repo %q", m[1], dat.Repo)
    			registry.shame = append(registry.shame, &appShame{
    				repository: fmt.Sprintf("<app-list toml: %q>", m[1]),
    				errorMsg:   fmt.Sprintf("invalid repo %q", dat.Repo),
    			})
    			continue
    		}
    		registry.apps = append(registry.apps, &appDescriptor{
    			repository: dat.Repo,
    			pathInRepo: dat.PathInRepo,
    		})
    	}
    
    	for _, app := range registry.apps {
    		info, err := s.getAppInfo(ctx, app.pathInRepo, app.repository)
    		if err != nil {
    			log.Printf("App %q: %v", app.repository, err)
    			registry.shame = append(registry.shame, &appShame{
    				repository: app.repository,
    				errorMsg:   fmt.Sprintf("%v", err),
    			})
    			continue
    		} else if info.firstErr != nil {
    			log.Printf("App: %q: was okay, latest has error: %v", app.repository, info.firstErr)
    			registry.shame = append(registry.shame, &appShame{
    				repository: app.repository,
    				errorMsg:   fmt.Sprintf("%v", info.firstErr),
    			})
    		} else {
    			log.Printf("App: %q: okay", app.repository)
    		}
    		app.appInfo = info
    	}
    
    	log.Printf("Got %d apps and %d apps with shame", len(registry.apps), len(registry.shame))
    
    	return &registry, nil
    }
    
    type jsonApp struct {
    	RepoURL        string    `json:"repoUrl"`
    	Commit         string    `json:"commit"`
    	DownloadURL    string    `json:"downloadUrl"`
    	TarDownloadURL string    `json:"tarDownloadUrl"`
    	Name           string    `json:"name"`
    	Menu           string    `json:"menu"`
    	Author         string    `json:"author"`
    	Description    string    `json:"description"`
    	Version        int       `json:"version"`
    	Timestamp      time.Time `json:"timestamp"`
    	Stars          int       `json:"stars"`
    	Flow3rSeed     string    `json:"flow3rSeed"`
    }
    
    func makeJsonApp(a *appDescriptor) jsonApp {
    	return jsonApp{
    		RepoURL:        "https://git.flow3r.garden/" + a.repository,
    		Commit:         a.appInfo.commit,
    		DownloadURL:    fmt.Sprintf("%sapps/zip/%s.zip", flagBaseURL, a.repository),
    		TarDownloadURL: fmt.Sprintf("%sapps/tar/%s.tar.gz", flagBaseURL, a.repository),
    		Name:           a.appInfo.name,
    		Menu:           a.appInfo.menu,
    		Author:         a.appInfo.author,
    		Description:    a.appInfo.description,
    		Version:        a.appInfo.version,
    		Timestamp:      a.appInfo.commitObj.Committer.When.UTC(),
    		Stars:          a.appInfo.stars,
    		Flow3rSeed:     a.appInfo.flow3rSeed,
    	}
    }
    
    func (s *server) handleApps(w http.ResponseWriter, r *http.Request) {
    	ctx := r.Context()
    	apps, err := s.getApps(ctx)
    	if err != nil {
    		return
    	}
    
    	type res struct {
    		DT   time.Time `json:"isodatetime"`
    		Apps []jsonApp `json:"apps"`
    	}
    
    	resp := res{
    		DT: time.Now().UTC(),
    	}
    	for _, a := range apps {
    		if a.appInfo == nil {
    			continue
    		}
    		// Guarenteed to be exactly 2 parts because of the regex
    		resp.Apps = append(resp.Apps, makeJsonApp(a))
    	}
    	w.Header().Add("Access-Control-Allow-Origin", "*")
    	w.Header().Add("Content-Type", "application/json")
    	j := json.NewEncoder(w)
    	j.Encode(resp)
    }
    
    var (
    	reAppURL = regexp.MustCompile("^/api/apps/([0-4]{8}).json$")
    )
    
    func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
    	ctx := r.Context()
    
    	matches := reAppURL.FindStringSubmatch(r.URL.Path)
    	if matches == nil {
    		http.NotFound(w, r)
    		return
    	}
    	flow3rSeed := matches[1]
    
    	apps, err := s.getApps(ctx)
    	if err != nil {
    		return
    	}
    
    	for _, a := range apps {
    		if a.appInfo == nil {
    			continue
    		}
    		if a.appInfo.flow3rSeed == flow3rSeed {
    			w.Header().Add("Access-Control-Allow-Origin", "*")
    			w.Header().Add("Content-Type", "application/json")
    			j := json.NewEncoder(w)
    			j.Encode(makeJsonApp(a))
    			return
    		}
    	}
    
    	http.NotFound(w, r)
    }
    
    func (s *server) handleAppShame(w http.ResponseWriter, r *http.Request) {
    	ctx := r.Context()
    	appShame, err := s.getAppShame(ctx)
    	if err != nil {
    		return
    	}
    
    	type jsonAppShame struct {
    		Repo     string `json:"repo"`
    		ErrorMsg string `json:"errorMsg"`
    	}
    
    	type res struct {
    		DT   time.Time      `json:"isodatetime"`
    		Apps []jsonAppShame `json:"shame"`
    	}
    
    	resp := res{
    		DT: time.Now().UTC(),
    	}
    	for _, shame := range appShame {
    		// Guarenteed to be exactly 2 parts because of the regex
    		resp.Apps = append(resp.Apps, jsonAppShame{
    			Repo:     shame.repository,
    			ErrorMsg: shame.errorMsg,
    		})
    	}
    	w.Header().Add("Access-Control-Allow-Origin", "*")
    	w.Header().Add("Content-Type", "application/json")
    	j := json.NewEncoder(w)
    	j.Encode(resp)
    }
    
    var (
    	reAppZipURL   = regexp.MustCompile(`^/api/apps/zip/([^/]+)/([^/]+)\.zip$`)
    	reAppTargzURL = regexp.MustCompile(`^/api/apps/tar/([^/]+)/([^/]+)\.tar\.gz$`)
    )
    
    func (s *server) handleAppZip(w http.ResponseWriter, r *http.Request) {
    	ctx := r.Context()
    
    	matches := reAppZipURL.FindStringSubmatch(r.URL.Path)
    	if matches == nil {
    		http.NotFound(w, r)
    		return
    	}
    	orga := matches[1]
    	repo := matches[2]
    
    	apps, err := s.getApps(ctx)
    	if err != nil {
    		return
    	}
    
    	for _, app := range apps {
    		if app.repository == fmt.Sprintf("%s/%s", orga, repo) {
    			w.Header().Add("Content-Type", "application/zip")
    			w.Write(app.appInfo.zip)
    			return
    		}
    	}
    
    	http.NotFound(w, r)
    }
    
    func (s *server) handleAppTargz(w http.ResponseWriter, r *http.Request) {
    	ctx := r.Context()
    
    	matches := reAppTargzURL.FindStringSubmatch(r.URL.Path)
    	if matches == nil {
    		http.NotFound(w, r)
    		return
    	}
    	orga := matches[1]
    	repo := matches[2]
    
    	apps, err := s.getApps(ctx)
    	if err != nil {
    		return
    	}
    
    	for _, app := range apps {
    		if app.repository == fmt.Sprintf("%s/%s", orga, repo) {
    			w.Header().Add("Content-Type", "application/x-gzip")
    			w.Write(app.appInfo.targz)
    			return
    		}
    	}
    
    	http.NotFound(w, r)
    }