An experimental IndieWeb site built in Go.
at main 6.3 kB view raw
1package services 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "mime/multipart" 9 "net/http" 10 urlpkg "net/url" 11 "os" 12 "reflect" 13 "strings" 14 15 "github.com/puregarlic/space/models" 16 "github.com/puregarlic/space/storage" 17 18 "github.com/aidarkhanov/nanoid" 19 "github.com/aws/aws-sdk-go-v2/aws" 20 "github.com/aws/aws-sdk-go-v2/service/s3" 21 "github.com/h2non/filetype" 22 "github.com/samber/lo" 23 24 "go.hacdias.com/indielib/microformats" 25 "go.hacdias.com/indielib/micropub" 26) 27 28type Micropub struct { 29 ProfileURL string 30} 31 32func postIdFromUrlPath(path string) string { 33 return strings.TrimPrefix(path, "/posts/") 34} 35 36func (m *Micropub) HasScope(r *http.Request, scope string) bool { 37 v := r.Context().Value(scopesContextKey) 38 if scopes, ok := v.([]string); ok { 39 for _, sc := range scopes { 40 if sc == scope { 41 return true 42 } 43 } 44 } 45 46 return false 47} 48 49func (m *Micropub) Source(urlStr string) (map[string]any, error) { 50 url, err := urlpkg.Parse(urlStr) 51 if err != nil { 52 return nil, fmt.Errorf("%w: %w", micropub.ErrBadRequest, err) 53 } 54 55 id := postIdFromUrlPath(url.Path) 56 post := &models.Post{} 57 58 res := storage.GORM().Find(post, "id = ?", id) 59 if res.Error != nil { 60 panic(res.Error) 61 } else if res.RowsAffected == 0 { 62 return nil, micropub.ErrNotFound 63 } 64 65 return map[string]any{ 66 "type": []string{post.Type}, 67 "properties": post.Properties, 68 }, nil 69} 70 71func (m *Micropub) SourceMany(limit, offset int) ([]map[string]any, error) { 72 return nil, micropub.ErrNotImplemented 73} 74 75func (m *Micropub) HandleMediaUpload(file multipart.File, header *multipart.FileHeader) (string, error) { 76 defer file.Close() 77 78 kind, err := filetype.MatchReader(file) 79 if _, err := file.Seek(0, 0); err != nil { 80 return "", fmt.Errorf("%w: %w", errors.New("failed to reset cursor"), err) 81 } 82 83 if err != nil { 84 return "", fmt.Errorf("%w: %w", errors.New("failed to upload"), err) 85 } 86 87 key := fmt.Sprintf("media/%s.%s", nanoid.New(), kind.Extension) 88 _, err = storage.S3().PutObject(context.TODO(), &s3.PutObjectInput{ 89 Bucket: aws.String(os.Getenv("AWS_S3_BUCKET_NAME")), 90 Key: &key, 91 Body: file, 92 }) 93 if err != nil { 94 return "", fmt.Errorf("%w: %w", errors.New("failed to upload"), err) 95 } 96 97 return m.ProfileURL + key, nil 98} 99 100func (m *Micropub) Create(req *micropub.Request) (string, error) { 101 props, err := json.Marshal(req.Properties) 102 if err != nil { 103 return "", err 104 } 105 106 mfType, _ := microformats.DiscoverType(map[string]any{ 107 "type": req.Type, 108 "properties": req.Properties, 109 }) 110 111 post := &models.Post{ 112 ID: models.NewULID(), 113 Type: req.Type, 114 MicroformatType: mfType, 115 Properties: props, 116 } 117 118 res := storage.GORM().Create(post) 119 if res.Error != nil { 120 return "", res.Error 121 } 122 123 return m.ProfileURL + "posts/" + post.ID.String(), nil 124} 125 126func (m *Micropub) Update(req *micropub.Request) (string, error) { 127 url, err := urlpkg.Parse(req.URL) 128 if err != nil { 129 return "", fmt.Errorf("%w: %w", micropub.ErrBadRequest, err) 130 } 131 132 id := postIdFromUrlPath(url.Path) 133 post := &models.Post{} 134 135 res := storage.GORM().Find(post, "id = ?", id) 136 if res.Error != nil { 137 panic(res.Error) 138 } else if res.RowsAffected != 1 { 139 return "", micropub.ErrNotFound 140 } 141 142 newProps, err := updateProperties(json.RawMessage(post.Properties), req) 143 if err != nil { 144 panic(err) 145 } 146 147 post.Properties = newProps 148 149 storage.GORM().Save(post) 150 151 return url.String(), nil 152} 153 154func (m *Micropub) Delete(urlStr string) error { 155 url, err := urlpkg.Parse(urlStr) 156 if err != nil { 157 return fmt.Errorf("%w: %w", micropub.ErrBadRequest, err) 158 } 159 160 id := postIdFromUrlPath(url.Path) 161 162 res := storage.GORM().Delete(&models.Post{}, "id = ?", id) 163 if res.Error != nil { 164 panic(res.Error) 165 } else if res.RowsAffected == 0 { 166 return fmt.Errorf("%w: %w", micropub.ErrNotFound, err) 167 } 168 169 return nil 170} 171 172func (m *Micropub) Undelete(urlStr string) error { 173 url, err := urlpkg.Parse(urlStr) 174 if err != nil { 175 return fmt.Errorf("%w: %w", micropub.ErrBadRequest, err) 176 } 177 178 id := postIdFromUrlPath(url.Path) 179 res := storage.GORM().Unscoped().Model(&models.Post{}).Where("id = ?", id).Update("deleted_at", nil) 180 if res.Error != nil { 181 return res.Error 182 } else if res.RowsAffected != 1 { 183 return micropub.ErrNotFound 184 } 185 186 return nil 187} 188 189// updateProperties applies the updates (additions, deletions, replacements) 190// in the given [micropub.Request] to a set of existing microformats properties. 191func updateProperties(props json.RawMessage, req *micropub.Request) ([]byte, error) { 192 properties := make(map[string][]any) 193 if err := json.Unmarshal(props, &properties); err != nil { 194 panic(err) 195 } 196 197 if req.Updates.Replace != nil { 198 for key, value := range req.Updates.Replace { 199 properties[key] = value 200 } 201 } 202 203 if req.Updates.Add != nil { 204 for key, value := range req.Updates.Add { 205 switch key { 206 case "name": 207 return nil, errors.New("cannot add a new name") 208 case "content": 209 return nil, errors.New("cannot add content") 210 default: 211 if key == "published" { 212 if _, ok := properties["published"]; ok { 213 return nil, errors.New("cannot replace published through add method") 214 } 215 } 216 217 if _, ok := properties[key]; !ok { 218 properties[key] = []any{} 219 } 220 221 properties[key] = append(properties[key], value...) 222 } 223 } 224 } 225 226 if req.Updates.Delete != nil { 227 if reflect.TypeOf(req.Updates.Delete).Kind() == reflect.Slice { 228 toDelete, ok := req.Updates.Delete.([]any) 229 if !ok { 230 return nil, errors.New("invalid delete array") 231 } 232 233 for _, key := range toDelete { 234 delete(properties, fmt.Sprint(key)) 235 } 236 } else { 237 toDelete, ok := req.Updates.Delete.(map[string]any) 238 if !ok { 239 return nil, fmt.Errorf("invalid delete object: expected map[string]any, got: %s", reflect.TypeOf(req.Updates.Delete)) 240 } 241 242 for key, v := range toDelete { 243 value, ok := v.([]any) 244 if !ok { 245 return nil, fmt.Errorf("invalid value: expected []any, got: %s", reflect.TypeOf(value)) 246 } 247 248 if _, ok := properties[key]; !ok { 249 properties[key] = []any{} 250 } 251 252 properties[key] = lo.Filter(properties[key], func(ss any, _ int) bool { 253 for _, s := range value { 254 if s == ss { 255 return false 256 } 257 } 258 return true 259 }) 260 } 261 } 262 } 263 264 propJson, err := json.Marshal(&properties) 265 if err != nil { 266 panic(err) 267 } 268 269 return propJson, nil 270}