diff --git a/Dockerfile b/Dockerfile index d786009..e6ffa9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,16 @@ +# Build stage FROM golang:latest AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN go build -v -o svg-templater ./... +RUN CGO_ENABLED=0 GOOS=linux go build -v -o svg-templater ./cmd/svg-templater +# Final stage FROM alpine:latest WORKDIR /root/ +EXPOSE 3000 +VOLUME ["/var/lib/svg-templater"] +RUN apk add inkscape fontconfig COPY --from=builder /app/svg-templater /usr/local/bin/svg-templater CMD ["svg-templater"] diff --git a/cmd/svg-templater/main.go b/cmd/svg-templater/main.go index b6de69e..d441d36 100644 --- a/cmd/svg-templater/main.go +++ b/cmd/svg-templater/main.go @@ -1,19 +1,14 @@ package main import ( - "log" - "tomatentum.net/svg-templater/internal/command" - "tomatentum.net/svg-templater/internal/database" + "tomatentum.net/svg-templater/pkg/format" ) func main() { - if err := database.OpenSQLite(); err != nil { - log.Fatal("Failed opening DB:\n", err) - return + if !format.CheckResvg() { + panic("Resvg not found") } - defer database.Close() - database.InitDB() command.PrepareCommandLine() command.HandleCommandline() } diff --git a/go.mod b/go.mod index cbc3ca9..4e353ff 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,12 @@ module tomatentum.net/svg-templater go 1.24.9 +require github.com/glebarez/go-sqlite v1.22.0 + require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/glebarez/go-sqlite v1.22.0 github.com/google/uuid v1.5.0 // indirect + github.com/hymkor/exregexp-go v0.2.0 github.com/mattn/go-isatty v0.0.20 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/image v0.36.0 diff --git a/go.sum b/go.sum index 0d3af12..3481ff6 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hymkor/exregexp-go v0.2.0 h1:tyIB8S9gpUwiBi3aDXXXL2yIXqTMWZzW3hs+UNDqcRM= +github.com/hymkor/exregexp-go v0.2.0/go.mod h1:bm661vkJcg9TbcYNr7QOcOgGlr+Jxy1Qb84YjVON3bg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= diff --git a/internal/command/commandline.go b/internal/command/commandline.go index d6ba717..ffd60b0 100644 --- a/internal/command/commandline.go +++ b/internal/command/commandline.go @@ -2,7 +2,9 @@ package command import ( "flag" + "log" + "tomatentum.net/svg-templater/internal/database" "tomatentum.net/svg-templater/internal/server" "tomatentum.net/svg-templater/pkg/svg" ) @@ -23,6 +25,17 @@ func PrepareCommandLine() { func HandleCommandline() { flag.Parse() + + if !help { + svg.Storage = svg.NewFileStorage(datapath, "public", "fonts") + if err := database.OpenSQLite(datapath); err != nil { + log.Fatal("Failed opening DB:\n", err) + return + } + database.InitDB() + } + defer database.Close() + if generateTokenFlag { GenerateTokenCommand() } else if deleteTokenFlag { @@ -30,7 +43,7 @@ func HandleCommandline() { } else if help { flag.PrintDefaults() } else { - svg.Storage = svg.NewFileStorage(datapath, "fonts") + svg.Storage = svg.NewFileStorage(datapath, "public", "fonts") server.PrepareHTTP() server.Start() } diff --git a/internal/database/database.go b/internal/database/database.go index 64bb0a1..03a4d12 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -3,6 +3,7 @@ package database import ( "database/sql" "log" + "path/filepath" _ "github.com/glebarez/go-sqlite" ) @@ -11,8 +12,8 @@ const FILENAME string = "storage.db" var database *sql.DB -func OpenSQLite() error { - db, err := sql.Open("sqlite", FILENAME) +func OpenSQLite(basepath string) error { + db, err := sql.Open("sqlite", filepath.Join(basepath, FILENAME)) if err != nil { return err } diff --git a/internal/database/svgdb.go b/internal/database/svgdb.go index d91f2ee..97d03df 100644 --- a/internal/database/svgdb.go +++ b/internal/database/svgdb.go @@ -13,6 +13,8 @@ const SVGTABLECREATE string = ` );` const INSERTSVGSQL string = "INSERT INTO svg VALUES (?, ?);" +const GETSPECIFICSVGSQL string = "SELECT * FROM svg WHERE name = ?;" +const GETSVGSQL string = "SELECT * FROM svg;" func InsertSVG(data *svg.TemplateData) error { json, err := json.Marshal(data.TemplateKeys) @@ -26,3 +28,47 @@ func InsertSVG(data *svg.TemplateData) error { return nil } + +func GetSVG() ([]svg.TemplateData, error) { + result, err := database.Query(GETSVGSQL) + if err != nil { + return nil, err + } + defer result.Close() + templates := make([]svg.TemplateData, 0) + for result.Next() { + var ( + id string + keysjson []byte + keys []string + ) + if err := result.Scan(&id, &keysjson); err != nil { + return nil, err + } + if err := json.Unmarshal(keysjson, &keys); err != nil { + return nil, err + } + templates = append(templates, svg.TemplateData{Id: id, TemplateKeys: keys}) + } + + if err := result.Err(); err != nil { + return nil, err + } + return templates, nil +} + +func GetSpecificSVG(id string) (svg.TemplateData, error) { + result := database.QueryRow(GETSPECIFICSVGSQL, id) + + var ( + keysjson []byte + keys []string + ) + if err := result.Scan(&id, &keysjson); err != nil { + return svg.TemplateData{}, err + } + if err := json.Unmarshal(keysjson, &keys); err != nil { + return svg.TemplateData{}, err + } + return svg.TemplateData{Id: id, TemplateKeys: keys}, nil +} diff --git a/internal/routes/download.go b/internal/routes/download.go new file mode 100644 index 0000000..9235603 --- /dev/null +++ b/internal/routes/download.go @@ -0,0 +1,105 @@ +package routes + +import ( + "encoding/json" + "log" + "net/http" + "net/url" + "path/filepath" + "strconv" + + "tomatentum.net/svg-templater/pkg/format" + "tomatentum.net/svg-templater/pkg/svg/actions" +) + +type downloadRequest struct { + TemplateKeys map[string]string +} + +type downloadResponse struct { + Url string +} + +func DownloadSVG(w http.ResponseWriter, r *http.Request) { + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + http.Error(w, "Incorrect Content-Type. Needs application/json.", http.StatusUnsupportedMediaType) + return + } + var request downloadRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var err error + id := r.PathValue("id") + if id == "" { + http.Error(w, "No ID provided", http.StatusBadRequest) + return + } + + widthString := r.URL.Query().Get("w") + width := 0 + if widthString != "" { + width, err = strconv.Atoi(widthString) + if err != nil { + http.Error(w, "Invalid w parameter", http.StatusBadRequest) + return + } + } + + heightString := r.URL.Query().Get("h") + height := 0 + if heightString != "" { + height, err = strconv.Atoi(heightString) + if err != nil { + http.Error(w, "Invalid h parameter", http.StatusBadRequest) + return + } + } + + convParam := format.ConversionParameters{ + Format: r.URL.Query().Get("format"), + Width: width, + Height: height, + } + if convParam.Format == "" { + convParam.Format = "svg" + } + + templateParam := actions.TemplateParameters{ + Id: id, + Keys: request.TemplateKeys, + } + + filename, err := actions.ProvideFile(&templateParam, &convParam) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + urlparsed := getPublicUrl(r, filename) + log.Printf("Made %s available as %s\n", id, urlparsed) + response := downloadResponse{ + Url: urlparsed, + } + w.Header().Add("Content-Type", "application/json") + json, err := json.Marshal(response) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(json) +} + +func getPublicUrl(r *http.Request, path string) string { + newURL := url.URL{ + Host: r.Host, + Path: filepath.Join("public", path), + } + newURL.Scheme = "http" + if r.TLS != nil { + newURL.Scheme = "https" + } + return newURL.String() +} diff --git a/internal/routes/upload.go b/internal/routes/upload.go index b3fbb92..1c19dab 100644 --- a/internal/routes/upload.go +++ b/internal/routes/upload.go @@ -10,7 +10,7 @@ import ( "tomatentum.net/svg-templater/pkg/svg/actions" ) -func CreateSVG(writer http.ResponseWriter, r http.Request) { +func CreateSVG(writer http.ResponseWriter, r *http.Request) { contentType := r.Header.Get("Content-Type") if contentType != "image/svg+xml" { diff --git a/internal/server/http.go b/internal/server/http.go index a9e2f7b..0c8d58c 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -7,20 +7,28 @@ import ( "tomatentum.net/svg-templater/internal/routes" "tomatentum.net/svg-templater/pkg/auth" + "tomatentum.net/svg-templater/pkg/svg" ) func PrepareHTTP() { - registerAuthorized("/", func(w http.ResponseWriter, r *http.Request) { + registerAuthorizedFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "You are authorized!") }) - registerAuthorized("/svg/", func(w http.ResponseWriter, r *http.Request) { + registerAuthorizedFunc("/svg/", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - routes.CreateSVG(w, *r) + routes.CreateSVG(w, r) }) - registerAuthorized("/font/", func(w http.ResponseWriter, r *http.Request) { + registerAuthorizedFunc("/svg/{id}", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + routes.DownloadSVG(w, r) + }) + registerAuthorizedFunc("/font/", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": routes.GetFonts(w, r) @@ -28,6 +36,8 @@ func PrepareHTTP() { routes.AddFont(w, r) } }) + + http.Handle("/public/", http.StripPrefix("/public/", http.FileServer(svg.Storage.GetPublicDir()))) } func Start() { @@ -37,7 +47,11 @@ func Start() { } } -func registerAuthorized(path string, f func(w http.ResponseWriter, r *http.Request)) { - http.HandleFunc(path, auth.AuthMiddleware(http.HandlerFunc(f))) +func registerAuthorized(path string, handler http.Handler) { + http.HandleFunc(path, auth.AuthMiddleware(handler)) log.Println("Registered authorized handler for", path) } + +func registerAuthorizedFunc(path string, f func(w http.ResponseWriter, r *http.Request)) { + registerAuthorized(path, http.HandlerFunc(f)) +} diff --git a/pkg/format/formatconverter.go b/pkg/format/formatconverter.go new file mode 100644 index 0000000..1610752 --- /dev/null +++ b/pkg/format/formatconverter.go @@ -0,0 +1,96 @@ +package format + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "tomatentum.net/svg-templater/pkg/svg" +) + +type ConversionParameters struct { + Format string + Width, Height int +} + +func ConvertByte(svgblob []byte, param ConversionParameters) ([]byte, error) { + reader, err := ConvertReader(svgblob, param) + if err != nil { + return nil, err + } + defer reader.Close() + + result, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + return result, nil +} + +func ConvertReader(svgblob []byte, param ConversionParameters) (io.ReadCloser, error) { + if param.Format == "svg" { + return io.NopCloser(bytes.NewReader(svgblob)), nil + } + file, err := svg.CreateTemp(bytes.NewReader(svgblob), "svg") + if err != nil { + return nil, err + } + defer os.Remove(file) + + switch param.Format { + case "png": + return convertPNG(file, param) + } + return nil, errors.New("Format not supported") +} + +func convertPNG(input string, param ConversionParameters) (io.ReadCloser, error) { + var args []string + var outExt string + + fontsdir, err := svg.Storage.GetFontsDir() + if err != nil { + return nil, err + } + args = append(args, "--skip-system-fonts") + args = append(args, "--use-fonts-dir", fontsdir) + + if param.Width > 0 { + args = append(args, "-w", strconv.Itoa(param.Width)) + } + if param.Height > 0 { + args = append(args, "-h", strconv.Itoa(param.Height)) + } + + outFile := strings.TrimSuffix(input, filepath.Ext(input)) + outExt + args = append(args, input) + args = append(args, outFile) + + cmd := exec.Command("resvg", args...) + cmd.Dir = svg.TempDir + + out, err := cmd.CombinedOutput() + log.Println(string(out)) + if err != nil { + return nil, fmt.Errorf("resvg failed: %w: %s", err, string(out)) + } + + file, err := os.Open(outFile) + if err != nil { + return nil, err + } + + return file, nil +} + +func CheckResvg() bool { + _, err := exec.LookPath("resvg") + return err == nil +} diff --git a/pkg/svg/actions/download.go b/pkg/svg/actions/download.go new file mode 100644 index 0000000..28c977c --- /dev/null +++ b/pkg/svg/actions/download.go @@ -0,0 +1,22 @@ +package actions + +import ( + "bytes" + "log" + + "tomatentum.net/svg-templater/pkg/format" + "tomatentum.net/svg-templater/pkg/svg" +) + +func ProvideFile(r *TemplateParameters, conversion *format.ConversionParameters) (string, error) { + templatedSvgblob, err := Template(r) + if err != nil { + return "", err + } + log.Printf("Converting %s to format %s (w=%d,h=%d)", r.Id, conversion.Format, conversion.Width, conversion.Height) + result, err := format.ConvertByte(templatedSvgblob, *conversion) + if err != nil { + return "", err + } + return svg.Storage.CreatePublic(bytes.NewReader(result), conversion.Format) +} diff --git a/pkg/svg/actions/template.go b/pkg/svg/actions/template.go new file mode 100644 index 0000000..029d4ca --- /dev/null +++ b/pkg/svg/actions/template.go @@ -0,0 +1,66 @@ +package actions + +import ( + "errors" + "io" + "log" + "maps" + "regexp" + "slices" + + "github.com/hymkor/exregexp-go" + "tomatentum.net/svg-templater/internal/database" + "tomatentum.net/svg-templater/pkg/svg" +) + +type TemplateParameters struct { + Id string + Keys map[string]string +} + +func Template(r *TemplateParameters) ([]byte, error) { + mapkeys := slices.Collect(maps.Keys(r.Keys)) + ok, err := verifyTemplate(r.Id, mapkeys) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("Template does not exist.") + } + log.Printf("Replacing keys of %s template\n", r.Id) + + reader, err := svg.Storage.Get(r.Id) + if err != nil { + return nil, err + } + defer reader.Close() + svgblob, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + replaceAll(&svgblob, r.Keys) + log.Printf("Finished replacing keys of %s template\n", r.Id) + return svgblob, nil +} + +func replaceAll(svgblob *[]byte, keys map[string]string) { + regex := regexp.MustCompile(svg.KeyRegex) + *svgblob = []byte(exregexp.ReplaceAllStringSubmatchFunc(regex, string(*svgblob), func(s []string) string { + log.Printf("Replacing key %s with %s\n", s[1], keys[s[1]]) + return keys[s[1]] + })) +} + +func verifyTemplate(id string, keys []string) (bool, error) { + data, err := database.GetSpecificSVG(id) + if err != nil { + return false, err + } + + for _, key := range data.TemplateKeys { + if !slices.Contains(keys, key) { + return false, nil + } + } + return true, nil +} diff --git a/pkg/svg/actions/upload.go b/pkg/svg/actions/upload.go index a6d26a4..ea7577c 100644 --- a/pkg/svg/actions/upload.go +++ b/pkg/svg/actions/upload.go @@ -27,9 +27,9 @@ func Create(svgbuf []byte) (svg.TemplateData, error) { return data, nil } -func populateKeys(data *svg.TemplateData, svg []byte) { - regex := regexp.MustCompile(`\{\{\s*(.*?)\s*\}\}`) - result := regex.FindAllSubmatch(svg, -1) +func populateKeys(data *svg.TemplateData, svgblob []byte) { + regex := regexp.MustCompile(svg.KeyRegex) + result := regex.FindAllSubmatch(svgblob, -1) templateKeys := make([]string, len(result)) for i, matches := range result { diff --git a/pkg/svg/storage.go b/pkg/svg/storage.go index 4724824..b5a7b9b 100644 --- a/pkg/svg/storage.go +++ b/pkg/svg/storage.go @@ -4,6 +4,7 @@ import ( "errors" "io" "log" + "net/http" "os" "path/filepath" "strings" @@ -13,18 +14,23 @@ import ( type SvgStorage interface { Create(id string, svg io.Reader) (string, error) - Get(id string) (io.Reader, error) + Get(id string) (io.ReadCloser, error) AddFont(reader io.Reader, format string) error GetFonts() ([]string, error) GetFontsDir() (string, error) + CreatePublic(data io.Reader, filetype string) (string, error) + GetPublic(path string) (io.ReadCloser, error) + GetPublicDir() http.Dir } +var _ SvgStorage = FileSvgStorage{} +var TempDir string = "" + type FileSvgStorage struct { - basepath string - fontssubpath string + basepath, publicSubPath, fontssubpath string } -func NewFileStorage(path string, fontssubpath string) *FileSvgStorage { +func NewFileStorage(path, publicSubPath, fontssubpath string) *FileSvgStorage { err := os.MkdirAll(path, 0755) if err != nil { panic(err) @@ -34,7 +40,7 @@ func NewFileStorage(path string, fontssubpath string) *FileSvgStorage { panic(err) } log.Println("Initialized file storage handler") - return &FileSvgStorage{path, fontssubpath} + return &FileSvgStorage{path, publicSubPath, fontssubpath} } func (f FileSvgStorage) Create(id string, svg io.Reader) (string, error) { @@ -54,14 +60,59 @@ func (f FileSvgStorage) Create(id string, svg io.Reader) (string, error) { return file.Name(), nil } -func (f FileSvgStorage) Get(id string) (io.Reader, error) { - file, err := os.Open(filepath.Join(f.basepath, "svg", id+".svg")) +func (f FileSvgStorage) Get(id string) (io.ReadCloser, error) { + file, err := os.Open(filepath.Join(f.basepath, id+".svg")) if err != nil { return nil, err } return file, nil } +func (f FileSvgStorage) CreatePublic(data io.Reader, filetype string) (string, error) { + path := filepath.Join(f.basepath, f.publicSubPath) + if err := os.MkdirAll(path, 0755); err != nil { + return "", err + } + + file, err := os.CreateTemp(path, "*."+filetype) + if err != nil { + return "", err + } + defer file.Close() + _, err = io.Copy(file, data) + if err != nil { + return "", err + } + + return strings.TrimPrefix(file.Name(), path), nil +} + +func (f FileSvgStorage) GetPublic(path string) (io.ReadCloser, error) { + file, err := os.Open(filepath.Join(f.basepath, f.publicSubPath, path)) + if err != nil { + return nil, err + } + return file, nil +} + +func (f FileSvgStorage) GetPublicDir() http.Dir { + return http.Dir(filepath.Join(f.basepath, f.publicSubPath)) +} + +func CreateTemp(data io.Reader, filetype string) (string, error) { + file, err := os.CreateTemp(TempDir, "*."+filetype) + if err != nil { + return "", err + } + defer file.Close() + _, err = io.Copy(file, data) + if err != nil { + return "", err + } + + return file.Name(), nil +} + func (f FileSvgStorage) AddFont(fontreader io.Reader, format string) error { if format != "ttf" && format != "otf" { return errors.New("Format not supported") diff --git a/pkg/svg/templates.go b/pkg/svg/templates.go index 398396e..6061f01 100644 --- a/pkg/svg/templates.go +++ b/pkg/svg/templates.go @@ -1,5 +1,7 @@ package svg +const KeyRegex string = `\{\{\s*(.*?)\s*\}\}` + type TemplateData struct { Id string TemplateKeys []string