An experimental IndieWeb site built in Go.
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}