add space and finish item create/update mutations.
This commit is contained in:
@@ -6,8 +6,11 @@ require (
|
|||||||
github.com/99designs/gqlgen v0.11.3
|
github.com/99designs/gqlgen v0.11.3
|
||||||
github.com/Masterminds/squirrel v1.2.0
|
github.com/Masterminds/squirrel v1.2.0
|
||||||
github.com/gin-gonic/gin v1.6.2
|
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/go-sql-driver/mysql v1.5.0
|
||||||
github.com/jmoiron/sqlx v1.2.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/pkg/errors v0.9.1
|
||||||
github.com/pressly/goose v2.6.0+incompatible
|
github.com/pressly/goose v2.6.0+incompatible
|
||||||
github.com/stretchr/testify v1.5.1
|
github.com/stretchr/testify v1.5.1
|
||||||
|
|||||||
@@ -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 h1:88crIK23zO6TqlQBt+f9FrPJNKm9ZEr7qjp9vl/d5TM=
|
||||||
github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
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-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 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
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=
|
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-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 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
|
||||||
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
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 h1:zCoDWFD5nrJJVjbXiDZcVhOBSzKn3o9LgRLLMRNuru8=
|
||||||
github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
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=
|
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/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-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-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/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/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=
|
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 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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.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/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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ package resolvers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"log"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.aiterp.net/stufflog/server/graph/graphcore"
|
"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/internal/slerrors"
|
||||||
"git.aiterp.net/stufflog/server/models"
|
"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")
|
return nil, errors.New("at least one tag is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.Image != nil {
|
|
||||||
panic("todo: implement image upload")
|
|
||||||
}
|
|
||||||
|
|
||||||
item := models.Item{
|
item := models.Item{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
Description: input.Description,
|
Description: input.Description,
|
||||||
@@ -152,11 +150,105 @@ func (r *mutationResolver) CreateItem(ctx context.Context, input *graphcore.Item
|
|||||||
ImageURL: nil,
|
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)
|
return r.Database.Items().Insert(ctx, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) EditItem(ctx context.Context, input *graphcore.ItemEditInput) (*models.Item, error) {
|
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) {
|
func (r *mutationResolver) CreateIssue(ctx context.Context, input graphcore.IssueCreateInput) (*models.Issue, error) {
|
||||||
|
|||||||
@@ -40,14 +40,17 @@ input ItemCreateInput {
|
|||||||
|
|
||||||
"Input for the editItem mutation."
|
"Input for the editItem mutation."
|
||||||
input ItemEditInput {
|
input ItemEditInput {
|
||||||
|
"The item to edit."
|
||||||
|
itemId: String!
|
||||||
|
|
||||||
"Update the name."
|
"Update the name."
|
||||||
setName: String
|
setName: String
|
||||||
"Update the description."
|
"Update the description."
|
||||||
setDescription: String
|
setDescription: String
|
||||||
"Add new tags. The tags are added after removeTag tags are removed."
|
"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."
|
"Remove existing tags. If a tag exists both here and in addTags, it will not be removed."
|
||||||
removeTags: [String]
|
removeTags: [String!]
|
||||||
"Update quantity unit."
|
"Update quantity unit."
|
||||||
setQuantityUnit: String
|
setQuantityUnit: String
|
||||||
"Clear quantity unit."
|
"Clear quantity unit."
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,10 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sort"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.aiterp.net/stufflog/server/database"
|
"git.aiterp.net/stufflog/server/database"
|
||||||
"git.aiterp.net/stufflog/server/graph"
|
"git.aiterp.net/stufflog/server/graph"
|
||||||
"git.aiterp.net/stufflog/server/internal/generate"
|
"git.aiterp.net/stufflog/server/internal/generate"
|
||||||
"git.aiterp.net/stufflog/server/internal/slerrors"
|
"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/models"
|
||||||
"git.aiterp.net/stufflog/server/services"
|
"git.aiterp.net/stufflog/server/services"
|
||||||
"github.com/99designs/gqlgen/graphql/playground"
|
"github.com/99designs/gqlgen/graphql/playground"
|
||||||
@@ -13,36 +21,101 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/pressly/goose"
|
"github.com/pressly/goose"
|
||||||
"github.com/urfave/cli/v2"
|
"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() {
|
func main() {
|
||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
Name: "stufflog",
|
Name: "stufflog",
|
||||||
Usage: "Issue tracker for your home and hobbies",
|
Usage: "Issue tracker for your home and hobbies",
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "db-driver",
|
Name: "db-driver",
|
||||||
Value: "mysql",
|
Value: "mysql",
|
||||||
Usage: "Database driver",
|
Usage: "Database driver",
|
||||||
EnvVars: []string{"DATABASE_DRIVER"},
|
EnvVars: []string{"DATABASE_DRIVER"},
|
||||||
|
Destination: &dbDriver,
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "db-connect",
|
Name: "db-connect",
|
||||||
Value: "stufflog_user:stuff1234@(localhost:3306)/stufflog",
|
Value: "stufflog_user:stuff1234@(localhost:3306)/stufflog",
|
||||||
Usage: "Database connection string or path",
|
Usage: "Database connection string or path",
|
||||||
EnvVars: []string{"DATABASE_CONNECT"},
|
EnvVars: []string{"DATABASE_CONNECT"},
|
||||||
|
Destination: &dbConnect,
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "listen",
|
Name: "listenAddress",
|
||||||
Value: ":8000",
|
Value: ":8000",
|
||||||
Usage: "Address to bind the server to.",
|
Usage: "Address to bind the server to.",
|
||||||
EnvVars: []string{"SERVER_LISTEN"},
|
EnvVars: []string{"SERVER_LISTEN"},
|
||||||
|
Destination: &listenAddress,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
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{
|
Commands: []*cli.Command{
|
||||||
@@ -50,7 +123,7 @@ func main() {
|
|||||||
Name: "reset-admin",
|
Name: "reset-admin",
|
||||||
Usage: "Reset the admin user (or create it)",
|
Usage: "Reset the admin user (or create it)",
|
||||||
Action: func(c *cli.Context) error {
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "Failed to connect to database")
|
return errors.Wrap(err, "Failed to connect to database")
|
||||||
}
|
}
|
||||||
@@ -102,7 +175,7 @@ func main() {
|
|||||||
Name: "migrate",
|
Name: "migrate",
|
||||||
Usage: "Migrate the configured database",
|
Usage: "Migrate the configured database",
|
||||||
Action: func(c *cli.Context) error {
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "Failed to connect to database")
|
return errors.Wrap(err, "Failed to connect to database")
|
||||||
}
|
}
|
||||||
@@ -123,12 +196,20 @@ func main() {
|
|||||||
Name: "server",
|
Name: "server",
|
||||||
Usage: "Run the server",
|
Usage: "Run the server",
|
||||||
Action: func(c *cli.Context) error {
|
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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "Failed to connect to database")
|
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 := gin.New()
|
||||||
server.GET("/graphql", graph.Gin(bundle, db))
|
server.GET("/graphql", graph.Gin(bundle, db))
|
||||||
@@ -140,16 +221,16 @@ func main() {
|
|||||||
|
|
||||||
errCh := make(chan error)
|
errCh := make(chan error)
|
||||||
go func() {
|
go func() {
|
||||||
err := server.Run(c.String("listen"))
|
err := server.Run(listenAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case signal := <-exitSignal:
|
case sig := <-exitSignal:
|
||||||
{
|
{
|
||||||
log.Println("Received signal", signal)
|
log.Println("Received signal", sig)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
case err := <-errCh:
|
case err := <-errCh:
|
||||||
|
|||||||
+11
-4
@@ -1,19 +1,26 @@
|
|||||||
package services
|
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 {
|
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{
|
auth := &Auth{
|
||||||
users: db.Users(),
|
users: db.Users(),
|
||||||
session: db.Session(),
|
session: db.Session(),
|
||||||
projects: db.Projects(),
|
projects: db.Projects(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upload := &Upload{s3: s3}
|
||||||
|
|
||||||
return Bundle{
|
return Bundle{
|
||||||
Auth: auth,
|
Auth: auth,
|
||||||
|
Upload: upload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user