mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-16 04:12:16 +01:00
more tests + small refactoring (no more App struct)
This commit is contained in:
126
app/converter/converter.go
Normal file
126
app/converter/converter.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Package converter implements a solution for converting one or more files to PDF.
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
gfile "github.com/gulien/gotenberg/app/converter/file"
|
||||
"github.com/gulien/gotenberg/app/converter/process"
|
||||
ghttp "github.com/gulien/gotenberg/app/http"
|
||||
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// Converter handles conversion into PDF of files coming from a request.
|
||||
type Converter struct {
|
||||
files []*gfile.File
|
||||
workingDir string
|
||||
}
|
||||
|
||||
// NoFileToConvertError is raised when the converter has no file
|
||||
// to convert.
|
||||
type NoFileToConvertError struct{}
|
||||
|
||||
func (e *NoFileToConvertError) Error() string {
|
||||
return "There is no file to convert"
|
||||
}
|
||||
|
||||
// FilesKeyNotFoundError is raised when "files" key does not exist
|
||||
// in the form data
|
||||
type FilesKeyNotFoundError struct{}
|
||||
|
||||
func (e *FilesKeyNotFoundError) Error() string {
|
||||
return "\"files\" key was not found in the form data"
|
||||
}
|
||||
|
||||
// NewConverter instantiates a converter by parsing a request.
|
||||
func NewConverter(r *http.Request, contentType ghttp.ContentType) (*Converter, error) {
|
||||
c := &Converter{
|
||||
workingDir: fmt.Sprintf("./%s/", uuid.NewV4().String()),
|
||||
}
|
||||
|
||||
if err := os.Mkdir(c.workingDir, 0666); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch contentType {
|
||||
case ghttp.MultipartFormDataContentType:
|
||||
err := r.ParseMultipartForm(32 << 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
formData := r.MultipartForm
|
||||
files, ok := formData.File["files"]
|
||||
if !ok {
|
||||
return nil, &FilesKeyNotFoundError{}
|
||||
}
|
||||
|
||||
for i := range files {
|
||||
file, err := files[i].Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
f, err := gfile.NewFile(c.workingDir, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.files = append(c.files, f)
|
||||
}
|
||||
break
|
||||
default:
|
||||
f, err := gfile.NewFile(c.workingDir, r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.files = append(c.files, f)
|
||||
}
|
||||
|
||||
if len(c.files) == 0 {
|
||||
return nil, &NoFileToConvertError{}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Convert converts its associated files to PDF. If more than one file,
|
||||
// it will merge all of them into one unique PDF file.
|
||||
// Returns the new file path or an error if something bad happened.
|
||||
func (c *Converter) Convert() (string, error) {
|
||||
var filesPaths []string
|
||||
for _, f := range c.files {
|
||||
if f.Type != gfile.PDFType {
|
||||
path, err := process.Unconv(c.workingDir, f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filesPaths = append(filesPaths, path)
|
||||
} else {
|
||||
filesPaths = append(filesPaths, f.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filesPaths) == 1 {
|
||||
return filesPaths[0], nil
|
||||
}
|
||||
|
||||
path, err := process.Merge(c.workingDir, filesPaths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Clear removes all file inside its working directory.
|
||||
func (c *Converter) Clear() error {
|
||||
return os.RemoveAll(c.workingDir)
|
||||
}
|
||||
152
app/converter/file/file.go
Normal file
152
app/converter/file/file.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Package file implements a solution for handling files coming from a request.
|
||||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
ghttp "github.com/gulien/gotenberg/app/http"
|
||||
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// File represents a file which has been created
|
||||
// from a request.
|
||||
type File struct {
|
||||
// Type is the kind of file.
|
||||
Type Type
|
||||
// Path is the file path.
|
||||
Path string
|
||||
}
|
||||
|
||||
// Type represents what kind of file we're dealing with.
|
||||
type Type uint32
|
||||
|
||||
const (
|
||||
// PDFType represents a... PDF file.
|
||||
PDFType Type = iota
|
||||
// HTMLType represents an... HTML file.
|
||||
HTMLType
|
||||
// OfficeType represents an... Office document.
|
||||
OfficeType
|
||||
)
|
||||
|
||||
// NewFile creates a file in the considered directory.
|
||||
// Returns a *File instance or an error if something bad happened.
|
||||
func NewFile(workingDir string, r io.Reader) (*File, error) {
|
||||
f := &File{
|
||||
Path: MakeFilePath(workingDir),
|
||||
}
|
||||
|
||||
file, err := os.Create(f.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// resets the read pointer.
|
||||
file.Seek(0, 0)
|
||||
|
||||
t, err := findFileType(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.Type = t
|
||||
|
||||
f, err = reworkFilePath(workingDir, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// MakeFilePath is a simple helper which generates a random file name
|
||||
// and associates it with the considered directory to make a path.
|
||||
func MakeFilePath(workingDir string) string {
|
||||
return fmt.Sprintf("%s%s", workingDir, uuid.NewV4().String())
|
||||
}
|
||||
|
||||
// filesTypes associates a content type with its file kind counterpart.
|
||||
var filesTypes = map[ghttp.ContentType]Type{
|
||||
ghttp.PDFContentType: PDFType,
|
||||
ghttp.HTMLContentType: HTMLType,
|
||||
ghttp.OctetStreamContentType: OfficeType,
|
||||
ghttp.ZipContentType: OfficeType,
|
||||
}
|
||||
|
||||
type fileTypeNotFound struct{}
|
||||
|
||||
func (e *fileTypeNotFound) Error() string {
|
||||
return "The file type was not found for the given 'Content-Type'"
|
||||
}
|
||||
|
||||
// findFileType tries to detect what kind of file is the given file.
|
||||
func findFileType(f *os.File) (Type, error) {
|
||||
ct, err := ghttp.SniffContentType(f)
|
||||
if err != nil {
|
||||
return 999, err
|
||||
}
|
||||
|
||||
t, ok := filesTypes[ct]
|
||||
if !ok {
|
||||
return 999, &fileTypeNotFound{}
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Ext represents a file extension.
|
||||
type Ext string
|
||||
|
||||
const (
|
||||
// PDFExt represents a... PDF extension.
|
||||
PDFExt Ext = ".pdf"
|
||||
// HTMLExt represents an... HTML extension.
|
||||
HTMLExt Ext = ".html"
|
||||
// OfficeExt is a empty string, as Office documents
|
||||
// have a lot of different extensions (.docx, .doc and so on).
|
||||
OfficeExt Ext = ""
|
||||
)
|
||||
|
||||
// filesExtensions associates a kind of file with its extension.
|
||||
var filesExtensions = map[Type]Ext{
|
||||
PDFType: PDFExt,
|
||||
HTMLType: HTMLExt,
|
||||
OfficeType: OfficeExt,
|
||||
}
|
||||
|
||||
type fileExtNotFound struct{}
|
||||
|
||||
func (e *fileExtNotFound) Error() string {
|
||||
return "The file extension was not found for the given file type"
|
||||
}
|
||||
|
||||
// reworkFilePath renames a file in the considered directory and adds its extension.
|
||||
func reworkFilePath(workingDir string, f *File) (*File, error) {
|
||||
ext, ok := filesExtensions[f.Type]
|
||||
if !ok {
|
||||
return nil, &fileExtNotFound{}
|
||||
}
|
||||
|
||||
if ext != OfficeExt {
|
||||
newPath := fmt.Sprintf("%s%s", MakeFilePath(workingDir), ext)
|
||||
|
||||
err := os.Rename(f.Path, newPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.Path = newPath
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
52
app/converter/file/file_test.go
Normal file
52
app/converter/file/file_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewFile(t *testing.T) {
|
||||
workingDir := "test"
|
||||
os.Mkdir(workingDir, 0666)
|
||||
|
||||
// case 1: uses an empty reader.
|
||||
if _, err := NewFile(workingDir, new(bytes.Buffer)); err == nil {
|
||||
t.Error("File should not have been instantiated!")
|
||||
}
|
||||
|
||||
// case 2: uses a reader from a wrong file type.
|
||||
path, _ := filepath.Abs("../../../_tests/configurations/gotenberg.yml")
|
||||
r, _ := os.Open(path)
|
||||
defer r.Close()
|
||||
if _, err := NewFile(workingDir, r); err == nil {
|
||||
t.Error("File should not have been instantiated!")
|
||||
}
|
||||
|
||||
// case 3: uses a reader from a correct file type.
|
||||
path, _ = filepath.Abs("../../../_tests/file.pdf")
|
||||
r, _ = os.Open(path)
|
||||
defer r.Close()
|
||||
if _, err := NewFile(workingDir, r); err != nil {
|
||||
t.Error("File should have been instantiated!")
|
||||
}
|
||||
|
||||
os.RemoveAll(workingDir)
|
||||
}
|
||||
|
||||
func TestReworkFilePath(t *testing.T) {
|
||||
workingDir := "test"
|
||||
os.Mkdir(workingDir, 0666)
|
||||
|
||||
f := &File{
|
||||
Path: MakeFilePath(workingDir),
|
||||
Type: 999,
|
||||
}
|
||||
|
||||
if _, err := reworkFilePath(workingDir, f); err == nil {
|
||||
t.Error("It should not have been able to found the file extension!")
|
||||
}
|
||||
|
||||
os.RemoveAll(workingDir)
|
||||
}
|
||||
135
app/converter/process/process.go
Normal file
135
app/converter/process/process.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Package process handles all commands executions.
|
||||
package process
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/gulien/gotenberg/app/config"
|
||||
gfile "github.com/gulien/gotenberg/app/converter/file"
|
||||
)
|
||||
|
||||
var commandsConfig *config.CommandsConfig
|
||||
|
||||
// Load loads the commands configuration coming from the application configuration.
|
||||
func Load(config *config.CommandsConfig) {
|
||||
commandsConfig = config
|
||||
}
|
||||
|
||||
// conversionData will be applied to the data-driven templates of conversions commands.
|
||||
type conversionData struct {
|
||||
FilePath string
|
||||
ResultFilePath string
|
||||
}
|
||||
|
||||
type impossibleConversionError struct{}
|
||||
|
||||
func (e *impossibleConversionError) Error() string {
|
||||
return "Impossible conversion"
|
||||
}
|
||||
|
||||
// Unconv converts a file to PDF and returns the new file path.
|
||||
func Unconv(workingDir string, file *gfile.File) (string, error) {
|
||||
cmdData := &conversionData{
|
||||
FilePath: file.Path,
|
||||
ResultFilePath: fmt.Sprintf("%s%s", gfile.MakeFilePath(workingDir), gfile.PDFExt),
|
||||
}
|
||||
|
||||
var (
|
||||
cmdTemplate *template.Template
|
||||
cmdTimeout int
|
||||
)
|
||||
|
||||
switch file.Type {
|
||||
case gfile.HTMLType:
|
||||
cmdTemplate = commandsConfig.HTML.Template
|
||||
cmdTimeout = commandsConfig.HTML.Timeout
|
||||
break
|
||||
case gfile.OfficeType:
|
||||
cmdTemplate = commandsConfig.Office.Template
|
||||
cmdTimeout = commandsConfig.Office.Timeout
|
||||
break
|
||||
default:
|
||||
return "", &impossibleConversionError{}
|
||||
}
|
||||
|
||||
var data bytes.Buffer
|
||||
if err := cmdTemplate.Execute(&data, cmdData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err := run(data.String(), cmdTimeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cmdData.ResultFilePath, nil
|
||||
}
|
||||
|
||||
// mergeData will be applied to the data-driven template of the merge command.
|
||||
type mergeData struct {
|
||||
FilesPaths []string
|
||||
ResultFilePath string
|
||||
}
|
||||
|
||||
// Merge merges many PDF files to one unique PDF file and returns the new file path.
|
||||
func Merge(workingDir string, filesPaths []string) (string, error) {
|
||||
cmdData := &mergeData{
|
||||
FilesPaths: filesPaths,
|
||||
ResultFilePath: fmt.Sprintf("%s%s", gfile.MakeFilePath(workingDir), gfile.PDFExt),
|
||||
}
|
||||
|
||||
cmdTemplate := commandsConfig.Merge.Template
|
||||
cmdTimeout := commandsConfig.Merge.Timeout
|
||||
|
||||
var data bytes.Buffer
|
||||
if err := cmdTemplate.Execute(&data, cmdData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err := run(data.String(), cmdTimeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cmdData.ResultFilePath, nil
|
||||
}
|
||||
|
||||
type commandTimeoutError struct{}
|
||||
|
||||
func (e *commandTimeoutError) Error() string {
|
||||
return "The command has reached timeout"
|
||||
}
|
||||
|
||||
// run runs the given command. If timeout is reached or
|
||||
// something bad happened, returns an error.
|
||||
func run(command string, timeout int) error {
|
||||
cmd := exec.Command("/bin/sh", "-c", command)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
// wait for the process to finish or kill it after a timeout.
|
||||
select {
|
||||
case <-time.After(time.Duration(timeout) * time.Second):
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return &commandTimeoutError{}
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user