Browse Source

add space and finish item create/update mutations.

master
Gisle Aune 4 years ago
parent
commit
51582210ca
  1. 3
      go.mod
  2. 8
      go.sum
  3. 104
      graph/resolvers/mutation.resolvers.go
  4. 7
      graph/schema/item.gql
  5. 22
      internal/slerrors/forbidden.go
  6. 130
      internal/space/space.go
  7. 131
      main.go
  8. 15
      services/bundle.go
  9. 53
      services/upload.go

3
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

8
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=

104
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) {

7
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."

22
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
}

130
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
}

131
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:

15
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,
}
}

53
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",
}
Loading…
Cancel
Save