Skip to content
Snippets Groups Projects
Select Git revision
  • a873a2e41c02fcc50eeb06ea1c77104d409b1ccb
  • main default protected
2 results

server_apps.go

Blame
  • Forked from flow3r / API
    37 commits behind the upstream repository.
    server_apps.go 12.39 KiB
    package main
    
    import (
    	"archive/tar"
    	"archive/zip"
    	"bytes"
    	"compress/gzip"
    	"context"
    	"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 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
    
    	commitObj *object.Commit
    	zip       []byte
    	targz     []byte
    }
    
    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)
    	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)
    		err = t.WriteHeader(&tar.Header{
    			Name:     outPath,
    			Typeflag: tar.TypeReg,
    			Size:     f.Size,
    		})
    		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 failed: %w", err)
    	}
    	if data.App.Name == "" || len(data.App.Name) > 32 {
    		return nil, fmt.Errorf("app name invalif")
    	}
    	sections := map[string]bool{
    		"Badge": true,
    		"Apps":  true,
    		"Music": true,
    	}
    	if !sections[data.App.Menu] {
    		return nil, fmt.Errorf("app menu invalid")
    	}
    	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")
    	}
    	if len(data.Metadata.Description) > 140 {
    		return nil, fmt.Errorf("metadata description too long")
    	}
    
    	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)
    	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 {
    			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 {
    		return nil, fmt.Errorf("no version")
    	}
    	stars, err := s.getStars(ctx, repo)
    	if err != nil {
    		return nil, fmt.Errorf("getting 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
    	return app, nil
    }
    
    func (s *server) getAppRegistry(ctx context.Context) ([]*appDescriptor, 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 res []*appDescriptor
    	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)
    			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)
    			continue
    		}
    		n := reAppRepo.FindStringSubmatch(dat.Repo)
    		if n == nil {
    			log.Printf("App %q: invalid repo %q", m[1], dat.Repo)
    			continue
    		}
    		res = append(res, &appDescriptor{
    			repository: dat.Repo,
    			pathInRepo: dat.PathInRepo,
    		})
    	}
    
    	for _, app := range res {
    		info, err := s.getAppInfo(ctx, app.pathInRepo, app.repository)
    		if err != nil {
    			log.Printf("App %q: %v", app.repository, err)
    			continue
    		} else {
    			log.Printf("App: %q: okay", app.repository)
    		}
    		app.appInfo = info
    	}
    
    	log.Printf("Got %d apps", len(res))
    	return res, nil
    }
    
    func (s *server) handleApps(w http.ResponseWriter, r *http.Request) {
    	ctx := r.Context()
    	apps, err := s.getApps(ctx)
    	if err != nil {
    		return
    	}
    
    	type app 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"`
    		Stars          int    `json:"stars"`
    	}
    
    	type res struct {
    		DT   time.Time `json:"isodatetime"`
    		Apps []app     `json:"apps"`
    	}
    
    	resp := res{
    		DT: time.Now(),
    	}
    	for _, a := range apps {
    		if a.appInfo == nil {
    			continue
    		}
    		// Guarenteed to be exactly 2 parts because of the regex
    		resp.Apps = append(resp.Apps, app{
    			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,
    			Stars:          a.appInfo.stars,
    		})
    	}
    	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)
    }