mirror of https://code.forgejo.org/forgejo/act
319 lines
7.3 KiB
Go
319 lines
7.3 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/julienschmidt/httprouter"
|
|
|
|
"github.com/nektos/act/pkg/common"
|
|
)
|
|
|
|
type FileContainerResourceURL struct {
|
|
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
|
|
}
|
|
|
|
type NamedFileContainerResourceURL struct {
|
|
Name string `json:"name"`
|
|
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
|
|
}
|
|
|
|
type NamedFileContainerResourceURLResponse struct {
|
|
Count int `json:"count"`
|
|
Value []NamedFileContainerResourceURL `json:"value"`
|
|
}
|
|
|
|
type ContainerItem struct {
|
|
Path string `json:"path"`
|
|
ItemType string `json:"itemType"`
|
|
ContentLocation string `json:"contentLocation"`
|
|
}
|
|
|
|
type ContainerItemResponse struct {
|
|
Value []ContainerItem `json:"value"`
|
|
}
|
|
|
|
type ResponseMessage struct {
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type WritableFile interface {
|
|
io.WriteCloser
|
|
}
|
|
|
|
type WriteFS interface {
|
|
OpenWritable(name string) (WritableFile, error)
|
|
OpenAppendable(name string) (WritableFile, error)
|
|
}
|
|
|
|
type readWriteFSImpl struct {
|
|
}
|
|
|
|
func (fwfs readWriteFSImpl) Open(name string) (fs.File, error) {
|
|
return os.Open(name)
|
|
}
|
|
|
|
func (fwfs readWriteFSImpl) OpenWritable(name string) (WritableFile, error) {
|
|
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
|
|
return nil, err
|
|
}
|
|
return os.OpenFile(name, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0o644)
|
|
}
|
|
|
|
func (fwfs readWriteFSImpl) OpenAppendable(name string) (WritableFile, error) {
|
|
if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
|
|
return nil, err
|
|
}
|
|
file, err := os.OpenFile(name, os.O_CREATE|os.O_RDWR, 0o644)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = file.Seek(0, io.SeekEnd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
var gzipExtension = ".gz__"
|
|
|
|
func safeResolve(baseDir string, relPath string) string {
|
|
return filepath.Join(baseDir, filepath.Clean(filepath.Join(string(os.PathSeparator), relPath)))
|
|
}
|
|
|
|
func uploads(router *httprouter.Router, baseDir string, fsys WriteFS) {
|
|
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
|
runID := params.ByName("runId")
|
|
|
|
json, err := json.Marshal(FileContainerResourceURL{
|
|
FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID),
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
_, err = w.Write(json)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
|
|
router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
|
itemPath := req.URL.Query().Get("itemPath")
|
|
runID := params.ByName("runId")
|
|
|
|
if req.Header.Get("Content-Encoding") == "gzip" {
|
|
itemPath += gzipExtension
|
|
}
|
|
|
|
safeRunPath := safeResolve(baseDir, runID)
|
|
safePath := safeResolve(safeRunPath, itemPath)
|
|
|
|
file, err := func() (WritableFile, error) {
|
|
contentRange := req.Header.Get("Content-Range")
|
|
if contentRange != "" && !strings.HasPrefix(contentRange, "bytes 0-") {
|
|
return fsys.OpenAppendable(safePath)
|
|
}
|
|
return fsys.OpenWritable(safePath)
|
|
}()
|
|
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer file.Close()
|
|
|
|
writer, ok := file.(io.Writer)
|
|
if !ok {
|
|
panic(errors.New("File is not writable"))
|
|
}
|
|
|
|
if req.Body == nil {
|
|
panic(errors.New("No body given"))
|
|
}
|
|
|
|
_, err = io.Copy(writer, req.Body)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
json, err := json.Marshal(ResponseMessage{
|
|
Message: "success",
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
_, err = w.Write(json)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
|
|
router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
|
json, err := json.Marshal(ResponseMessage{
|
|
Message: "success",
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
_, err = w.Write(json)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func downloads(router *httprouter.Router, baseDir string, fsys fs.FS) {
|
|
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
|
runID := params.ByName("runId")
|
|
|
|
safePath := safeResolve(baseDir, runID)
|
|
|
|
entries, err := fs.ReadDir(fsys, safePath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
var list []NamedFileContainerResourceURL
|
|
for _, entry := range entries {
|
|
list = append(list, NamedFileContainerResourceURL{
|
|
Name: entry.Name(),
|
|
FileContainerResourceURL: fmt.Sprintf("http://%s/download/%s", req.Host, runID),
|
|
})
|
|
}
|
|
|
|
json, err := json.Marshal(NamedFileContainerResourceURLResponse{
|
|
Count: len(list),
|
|
Value: list,
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
_, err = w.Write(json)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
|
|
router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
|
container := params.ByName("container")
|
|
itemPath := req.URL.Query().Get("itemPath")
|
|
safePath := safeResolve(baseDir, filepath.Join(container, itemPath))
|
|
|
|
var files []ContainerItem
|
|
err := fs.WalkDir(fsys, safePath, func(path string, entry fs.DirEntry, err error) error {
|
|
if !entry.IsDir() {
|
|
rel, err := filepath.Rel(safePath, path)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// if it was upload as gzip
|
|
rel = strings.TrimSuffix(rel, gzipExtension)
|
|
path := filepath.Join(itemPath, rel)
|
|
|
|
rel = filepath.ToSlash(rel)
|
|
path = filepath.ToSlash(path)
|
|
|
|
files = append(files, ContainerItem{
|
|
Path: path,
|
|
ItemType: "file",
|
|
ContentLocation: fmt.Sprintf("http://%s/artifact/%s/%s/%s", req.Host, container, itemPath, rel),
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
json, err := json.Marshal(ContainerItemResponse{
|
|
Value: files,
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
_, err = w.Write(json)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
|
|
router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
|
path := params.ByName("path")[1:]
|
|
|
|
safePath := safeResolve(baseDir, path)
|
|
|
|
file, err := fsys.Open(safePath)
|
|
if err != nil {
|
|
// try gzip file
|
|
file, err = fsys.Open(safePath + gzipExtension)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
w.Header().Add("Content-Encoding", "gzip")
|
|
}
|
|
|
|
_, err = io.Copy(w, file)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func Serve(ctx context.Context, artifactPath string, addr string, port string) context.CancelFunc {
|
|
serverContext, cancel := context.WithCancel(ctx)
|
|
logger := common.Logger(serverContext)
|
|
|
|
if artifactPath == "" {
|
|
return cancel
|
|
}
|
|
|
|
router := httprouter.New()
|
|
|
|
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
|
fsys := readWriteFSImpl{}
|
|
uploads(router, artifactPath, fsys)
|
|
downloads(router, artifactPath, fsys)
|
|
|
|
server := &http.Server{
|
|
Addr: fmt.Sprintf("%s:%s", addr, port),
|
|
ReadHeaderTimeout: 2 * time.Second,
|
|
Handler: router,
|
|
}
|
|
|
|
// run server
|
|
go func() {
|
|
logger.Infof("Start server on http://%s:%s", addr, port)
|
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logger.Fatal(err)
|
|
}
|
|
}()
|
|
|
|
// wait for cancel to gracefully shutdown server
|
|
go func() {
|
|
<-serverContext.Done()
|
|
|
|
if err := server.Shutdown(ctx); err != nil {
|
|
logger.Errorf("Failed shutdown gracefully - force shutdown: %v", err)
|
|
server.Close()
|
|
}
|
|
}()
|
|
|
|
return cancel
|
|
}
|