diff --git a/go.mod b/go.mod index 5d3a6e6..5fa39c0 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,11 @@ require ( github.com/99designs/gqlgen v0.11.3 github.com/Masterminds/squirrel v1.2.0 github.com/gin-gonic/gin v1.6.2 + github.com/go-ini/ini v1.56.0 // indirect github.com/go-sql-driver/mysql v1.5.0 github.com/jmoiron/sqlx v1.2.0 + github.com/minio/minio-go v6.0.14+incompatible + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/pkg/errors v0.9.1 github.com/pressly/goose v2.6.0+incompatible github.com/stretchr/testify v1.5.1 diff --git a/go.sum b/go.sum index 698baa0..a9a5274 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.6.2 h1:88crIK23zO6TqlQBt+f9FrPJNKm9ZEr7qjp9vl/d5TM= github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-ini/ini v1.56.0 h1:6HjxSjqdmgnujDPhlzR4a44lxK3w03WPN8te0SoUSeM= +github.com/go-ini/ini v1.56.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= @@ -68,6 +70,10 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/minio/minio-go v6.0.14+incompatible h1:fnV+GD28LeqdN6vT2XdGKW8Qe/IfjJDswNVuni6km9o= +github.com/minio/minio-go v6.0.14+incompatible/go.mod h1:7guKYtitv8dktvNUGrhzmNlA5wrAABTQXCoesZdFQO8= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047 h1:zCoDWFD5nrJJVjbXiDZcVhOBSzKn3o9LgRLLMRNuru8= github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= @@ -118,6 +124,7 @@ golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -126,6 +133,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/graph/resolvers/mutation.resolvers.go b/graph/resolvers/mutation.resolvers.go index af736d3..a9c78a9 100644 --- a/graph/resolvers/mutation.resolvers.go +++ b/graph/resolvers/mutation.resolvers.go @@ -6,10 +6,12 @@ package resolvers import ( "context" "errors" - "fmt" + "log" + "sort" "time" "git.aiterp.net/stufflog/server/graph/graphcore" + "git.aiterp.net/stufflog/server/internal/generate" "git.aiterp.net/stufflog/server/internal/slerrors" "git.aiterp.net/stufflog/server/models" ) @@ -140,10 +142,6 @@ func (r *mutationResolver) CreateItem(ctx context.Context, input *graphcore.Item return nil, errors.New("at least one tag is required") } - if input.Image != nil { - panic("todo: implement image upload") - } - item := models.Item{ Name: input.Name, Description: input.Description, @@ -152,11 +150,105 @@ func (r *mutationResolver) CreateItem(ctx context.Context, input *graphcore.Item ImageURL: nil, } + if input.Image != nil { + url, err := r.Upload.UploadImage( + ctx, + "item-image/"+generate.Generate(16, "I"), + input.Image.ContentType, + input.Image.Size, + input.Image.File, + ) + if err != nil { + return nil, err + } + + item.ImageURL = &url + } + return r.Database.Items().Insert(ctx, item) } func (r *mutationResolver) EditItem(ctx context.Context, input *graphcore.ItemEditInput) (*models.Item, error) { - panic(fmt.Errorf("not implemented")) + user := r.Auth.UserFromContext(ctx) + if user == nil { + return nil, slerrors.PermissionDenied + } + + item, err := r.Database.Items().Find(ctx, input.ItemID) + if err != nil { + return nil, err + } + + deleteList := make([]int, 0, len(input.RemoveTags)) + for _, tag := range input.RemoveTags { + for i, tag2 := range item.Tags { + if tag == tag2 { + deleteList = append(deleteList, i-len(deleteList)) + break + } + } + } + for _, index := range deleteList { + item.Tags = append(item.Tags[:index], item.Tags[index+1:]...) + } + for _, tag := range input.AddTags { + found := false + for _, tag2 := range item.Tags { + if tag == tag2 { + found = true + break + } + } + if !found { + item.Tags = append(item.Tags, tag) + } + } + sort.Strings(item.Tags) + + if input.SetQuantityUnit != nil { + item.QuantityUnit = input.SetQuantityUnit + } + if input.ClearQuantityUnit != nil && *input.ClearQuantityUnit { + item.QuantityUnit = nil + } + + if input.SetName != nil { + item.Name = *input.SetName + } + if input.SetDescription != nil { + item.Description = *input.SetDescription + } + + var prevFile *string + if input.UpdateImage != nil { + url, err := r.Upload.UploadImage( + ctx, + "item-image/"+generate.Generate(16, "U"), + input.UpdateImage.ContentType, + input.UpdateImage.Size, + input.UpdateImage.File, + ) + if err != nil { + return nil, err + } + + prevFile = item.ImageURL + item.ImageURL = &url + } + + err = r.Database.Items().Save(ctx, *item) + if err != nil { + return nil, err + } + + if prevFile != nil { + err := r.Upload.Delete(ctx, *prevFile) + if err != nil { + log.Printf("Failed to delete %s: %s", *prevFile, err) + } + } + + return item, nil } func (r *mutationResolver) CreateIssue(ctx context.Context, input graphcore.IssueCreateInput) (*models.Issue, error) { diff --git a/graph/schema/item.gql b/graph/schema/item.gql index f8921b0..419218a 100644 --- a/graph/schema/item.gql +++ b/graph/schema/item.gql @@ -40,14 +40,17 @@ input ItemCreateInput { "Input for the editItem mutation." input ItemEditInput { + "The item to edit." + itemId: String! + "Update the name." setName: String "Update the description." setDescription: String "Add new tags. The tags are added after removeTag tags are removed." - addTags: [String] + addTags: [String!] "Remove existing tags. If a tag exists both here and in addTags, it will not be removed." - removeTags: [String] + removeTags: [String!] "Update quantity unit." setQuantityUnit: String "Clear quantity unit." diff --git a/internal/slerrors/forbidden.go b/internal/slerrors/forbidden.go new file mode 100644 index 0000000..d69ce9f --- /dev/null +++ b/internal/slerrors/forbidden.go @@ -0,0 +1,22 @@ +package slerrors + +type forbiddenError struct { + Message string +} + +func (e *forbiddenError) Error() string { + return "forbidden: " + e.Message +} + +func Forbidden(message string) error { + return &forbiddenError{Message: message} +} + +func IsForbidden(err error) bool { + if err == nil { + return false + } + + _, ok := err.(*forbiddenError) + return ok +} diff --git a/internal/space/space.go b/internal/space/space.go new file mode 100644 index 0000000..a8b7311 --- /dev/null +++ b/internal/space/space.go @@ -0,0 +1,130 @@ +package space + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/minio/minio-go" +) + +var ErrBucketNotFound = errors.New("bucket not found") +var ErrFileTooBig = errors.New("file is too big") +var ErrNotEnabled = errors.New("s3 connection not enabled") +var ErrDifferentURLRoot = errors.New("url root in path is different from current") + +type Space struct { + bucket string + urlRoot string + root string + client *minio.Client + maxSize int64 +} + +func Connect(host, accessKey, secretKey, bucket string, secure bool, maxSize int64, rootDirectory, urlRoot string) (*Space, error) { + client, err := minio.New(host, accessKey, secretKey, secure) + if err != nil { + return nil, err + } + + exists, err := client.BucketExists(bucket) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrBucketNotFound + } + + if urlRoot == "" { + urlRoot = fmt.Sprintf("https://%s.%s/%s/", bucket, host, rootDirectory) + } else if !strings.HasSuffix(urlRoot, "/") { + urlRoot += "/" + } + + space := &Space{ + client: client, + bucket: bucket, + urlRoot: urlRoot, + maxSize: maxSize, + root: rootDirectory, + } + + return space, nil +} + +// UploadFile uploads the file to the space. This does not do any checks on it, so the endpoints should +// ensure that's all okay. +func (space *Space) UploadFile(ctx context.Context, name string, mimeType string, reader io.Reader, size int64) error { + if space == nil { + return ErrNotEnabled + } + + if size > space.maxSize { + return ErrFileTooBig + } + + _, err := space.client.PutObjectWithContext(ctx, space.bucket, space.root+"/"+name, reader, size, minio.PutObjectOptions{ + ContentType: mimeType, + UserMetadata: map[string]string{ + "x-amz-acl": "public-read", + }, + }) + if err != nil { + return err + } + + _, err = space.client.StatObject(space.bucket, space.root+"/"+name, minio.StatObjectOptions{}) + if err != nil { + return err + } + + return nil +} + +// RemoveFile removes a file from the space +func (space *Space) RemoveFile(ctx context.Context, name string) error { + if space == nil { + return ErrNotEnabled + } + + ch := make(chan string, 2) + ch <- space.root + "/" + name + close(ch) + + err := <-space.client.RemoveObjectsWithContext(ctx, space.bucket, ch) + + return err.Err +} + +// DownloadFile opens a file for download, using the same path format as the UploadFile function. Remember to Close it! +func (space *Space) DownloadFile(ctx context.Context, path string) (io.ReadCloser, error) { + if space == nil { + return nil, ErrNotEnabled + } + + return space.client.GetObjectWithContext(ctx, space.bucket, space.root+"/"+path, minio.GetObjectOptions{}) +} + +// URLFromPath gets the URL from the path returned by UploadFile +func (space *Space) URLFromPath(path string) string { + if space == nil { + return "" + } + + return space.urlRoot + path +} + +// URLFromPath gets the URL from the path returned by UploadFile +func (space *Space) PathFromURL(url string) (string, error) { + if space == nil { + return "", ErrNotEnabled + } + + if !strings.HasPrefix(url, space.urlRoot) { + return "", ErrDifferentURLRoot + } + + return url[len(space.urlRoot):], nil +} diff --git a/main.go b/main.go index 995a311..856e2eb 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,18 @@ package main import ( "context" + "log" + "os" + "os/signal" + "sort" + "syscall" + "time" + "git.aiterp.net/stufflog/server/database" "git.aiterp.net/stufflog/server/graph" "git.aiterp.net/stufflog/server/internal/generate" "git.aiterp.net/stufflog/server/internal/slerrors" + "git.aiterp.net/stufflog/server/internal/space" "git.aiterp.net/stufflog/server/models" "git.aiterp.net/stufflog/server/services" "github.com/99designs/gqlgen/graphql/playground" @@ -13,36 +21,101 @@ import ( "github.com/pkg/errors" "github.com/pressly/goose" "github.com/urfave/cli/v2" - "log" - "os" - "os/signal" - "sort" - "syscall" - "time" ) +var dbDriver string +var dbConnect string +var listenAddress string +var s3Host string +var s3AccessKey string +var s3SecretKey string +var s3BucketName string +var s3MaxFileSize int64 +var s3Secure bool +var s3RootDirectory string +var s3UrlRoot string + func main() { app := &cli.App{ Name: "stufflog", Usage: "Issue tracker for your home and hobbies", Flags: []cli.Flag{ &cli.StringFlag{ - Name: "db-driver", - Value: "mysql", - Usage: "Database driver", - EnvVars: []string{"DATABASE_DRIVER"}, + Name: "db-driver", + Value: "mysql", + Usage: "Database driver", + EnvVars: []string{"DATABASE_DRIVER"}, + Destination: &dbDriver, + }, + &cli.StringFlag{ + Name: "db-connect", + Value: "stufflog_user:stuff1234@(localhost:3306)/stufflog", + Usage: "Database connection string or path", + EnvVars: []string{"DATABASE_CONNECT"}, + Destination: &dbConnect, }, &cli.StringFlag{ - Name: "db-connect", - Value: "stufflog_user:stuff1234@(localhost:3306)/stufflog", - Usage: "Database connection string or path", - EnvVars: []string{"DATABASE_CONNECT"}, + Name: "listenAddress", + Value: ":8000", + Usage: "Address to bind the server to.", + EnvVars: []string{"SERVER_LISTEN"}, + Destination: &listenAddress, }, &cli.StringFlag{ - Name: "listen", - Value: ":8000", - Usage: "Address to bind the server to.", - EnvVars: []string{"SERVER_LISTEN"}, + Name: "s3-host", + Value: "localhost", + Usage: "S3 Host (without https://)", + EnvVars: []string{"S3_HOST"}, + Destination: &s3Host, + }, + &cli.StringFlag{ + Name: "s3-access-key", + Value: "", + Usage: "S3 access key (not the secret key)", + EnvVars: []string{"S3_ACCESS_KEY"}, + Destination: &s3AccessKey, + }, + &cli.StringFlag{ + Name: "s3-secret-key", + Value: "", + Usage: "S3 secret key", + EnvVars: []string{"S3_SECRET_KEY"}, + Destination: &s3SecretKey, + }, + &cli.StringFlag{ + Name: "s3-bucket-name", + Value: "stufflog", + Usage: "S3 bucket name", + EnvVars: []string{"S3_BUCKET"}, + Destination: &s3BucketName, + }, + &cli.Int64Flag{ + Name: "s3-max-file-size", + Value: 1024 * 1024, + Usage: "S3 maximum file size", + EnvVars: []string{"S3_MAX_FILE_SIZE"}, + Destination: &s3MaxFileSize, + }, + &cli.BoolFlag{ + Name: "s3-secure", + Usage: "Enable HTTPS for the s3 server", + EnvVars: []string{"S3_HTTPS", "S3_SECURE"}, + Value: false, + Destination: &s3Secure, + }, + &cli.StringFlag{ + Name: "s3-root-directory", + Value: "stufflog/userdata", + Usage: "Root directory under S3 server to use.", + EnvVars: []string{"S3_ROOT_DIRECTORY"}, + Destination: &s3RootDirectory, + }, + &cli.StringFlag{ + Name: "s3-url-root", + Value: "", + Usage: "The URL root for public URLs; includes the root directory. It's generated if blank.", + EnvVars: []string{"S3_URL_ROOT"}, + Destination: &s3UrlRoot, }, }, Commands: []*cli.Command{ @@ -50,7 +123,7 @@ func main() { Name: "reset-admin", Usage: "Reset the admin user (or create it)", Action: func(c *cli.Context) error { - db, err := database.Open(c.String("db-driver"), c.String("db-connect")) + db, err := database.Open(dbDriver, dbConnect) if err != nil { return errors.Wrap(err, "Failed to connect to database") } @@ -102,7 +175,7 @@ func main() { Name: "migrate", Usage: "Migrate the configured database", Action: func(c *cli.Context) error { - db, err := database.Open(c.String("db-driver"), c.String("db-connect")) + db, err := database.Open(dbDriver, dbConnect) if err != nil { return errors.Wrap(err, "Failed to connect to database") } @@ -123,12 +196,20 @@ func main() { Name: "server", Usage: "Run the server", Action: func(c *cli.Context) error { - db, err := database.Open(c.String("db-driver"), c.String("db-connect")) + db, err := database.Open(dbDriver, dbConnect) if err != nil { return errors.Wrap(err, "Failed to connect to database") } - bundle := services.NewBundle(db) + s3, err := space.Connect( + s3Host, s3AccessKey, s3SecretKey, s3BucketName, + s3Secure, s3MaxFileSize, s3RootDirectory, s3UrlRoot, + ) + if err != nil { + return err + } + + bundle := services.NewBundle(db, s3) server := gin.New() server.GET("/graphql", graph.Gin(bundle, db)) @@ -140,16 +221,16 @@ func main() { errCh := make(chan error) go func() { - err := server.Run(c.String("listen")) + err := server.Run(listenAddress) if err != nil { errCh <- err } }() select { - case signal := <-exitSignal: + case sig := <-exitSignal: { - log.Println("Received signal", signal) + log.Println("Received signal", sig) return nil } case err := <-errCh: diff --git a/services/bundle.go b/services/bundle.go index 2174180..cc84c3b 100644 --- a/services/bundle.go +++ b/services/bundle.go @@ -1,19 +1,26 @@ package services -import "git.aiterp.net/stufflog/server/database" +import ( + "git.aiterp.net/stufflog/server/database" + "git.aiterp.net/stufflog/server/internal/space" +) type Bundle struct { - Auth *Auth + Auth *Auth + Upload *Upload } -func NewBundle(db database.Database) Bundle { +func NewBundle(db database.Database, s3 *space.Space) Bundle { auth := &Auth{ users: db.Users(), session: db.Session(), projects: db.Projects(), } + upload := &Upload{s3: s3} + return Bundle{ - Auth: auth, + Auth: auth, + Upload: upload, } } diff --git a/services/upload.go b/services/upload.go new file mode 100644 index 0000000..9d300d7 --- /dev/null +++ b/services/upload.go @@ -0,0 +1,53 @@ +package services + +import ( + "context" + "git.aiterp.net/stufflog/server/internal/slerrors" + "git.aiterp.net/stufflog/server/internal/space" + "io" + "strings" +) + +type Upload struct { + s3 *space.Space +} + +func (upload *Upload) UploadImage(ctx context.Context, name string, contentType string, size int64, reader io.Reader) (string, error) { + if !allowedImages[contentType] { + return "", slerrors.Forbidden("Content type " + contentType + " is not allowed as an image.") + } + if !strings.HasSuffix(name, extensions[contentType]) { + name += extensions[contentType] + } + + err := upload.s3.UploadFile(ctx, name, contentType, reader, size) + if err != nil { + return "", err + } + + return upload.s3.URLFromPath(name), nil +} + +func (upload *Upload) Delete(ctx context.Context, url string) error { + name, err := upload.s3.PathFromURL(url) + if err != nil { + return err + } + + return upload.s3.RemoveFile(ctx, name) +} + +var allowedImages = map[string]bool{ + "image/jpg": true, + "image/jpeg": true, + "image/gif": true, + "image/png": true, + "image/bmp": false, +} + +var extensions = map[string]string{ + "image/jpg": ".jpg", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/png": ".png", +}