diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index 11ed862..35be2f7 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -36,10 +36,15 @@ const processors = [ ]; export default class UploadEditController extends Controller { - static targets = ['processList']; + static targets = ['processList', 'preview']; + static values = { + uploadId: Number, + siteId: Number, + }; connect() { this._rebuildProcessList(); + this._createSession(); } _rebuildProcessList() { @@ -56,4 +61,22 @@ export default class UploadEditController extends Controller { el.innerHTML = cardOuter; // END TEMP } + + async _createSession() { + try { + let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/`, { + method: 'POST', + body: JSON.stringify({ + "base_upload": this.uploadIdValue, + }) + }); + + this._state = await resp.json(); + this.previewTarget.src = this._state.preview_url; + + console.log("Session created"); + } catch (e) { + console.error(e); + } + } } \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 06f7352..5870a8a 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -111,6 +111,7 @@ Starting weiro without any arguments will start the server. lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth} ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories} uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} + ieh := handlers.ImageEditHandlers{ImageEditService: svcs.ImageEdit} ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} pgh := handlers.PagesHandler{PageService: svcs.Pages} @@ -151,6 +152,9 @@ Starting weiro without any arguments will start the server. siteGroup.Delete("/uploads/:uploadID", uh.Delete) siteGroup.Get("/uploads/:uploadID/edit", uh.Edit) + siteGroup.Post("/imageedit", ieh.Create) + siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) + siteGroup.Get("/settings", ssh.General) siteGroup.Post("/settings", ssh.UpdateGeneral) diff --git a/handlers/imageedit.go b/handlers/imageedit.go new file mode 100644 index 0000000..551776a --- /dev/null +++ b/handlers/imageedit.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "bufio" + "io" + "log" + "net/http" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/imgedit" +) + +type ImageEditHandlers struct { + ImageEditService *imgedit.Service +} + +func (ieh ImageEditHandlers) Create(c fiber.Ctx) error { + var req struct { + BaseUploadID int64 `json:"base_upload"` + } + + if err := c.Bind().JSON(&req); err != nil { + return err + } + + res, err := ieh.ImageEditService.NewSession(c.Context(), req.BaseUploadID) + if err != nil { + return err + } + + var resp = struct { + Session models.ImageEditSession `json:"session"` + PreviewURL string `json:"preview_url"` + }{ + Session: res, + PreviewURL: res.PreviewURL(), + } + + return c.Status(http.StatusCreated).JSON(resp) +} + +func (ieh ImageEditHandlers) Preview(c fiber.Ctx) error { + log.Printf("Previewing image edit session %v/%v", c.Params("sessionID"), c.Params("versionID")) + sessionID := c.Params("sessionID") + versionID := c.Params("versionID") + + mimeTime, rw, err := ieh.ImageEditService.LoadImageVersion(c.Context(), sessionID, versionID) + if err != nil { + return err + } + + c.Set("Content-Type", mimeTime) + c.Status(http.StatusOK) + return c.SendStreamWriter(func(w *bufio.Writer) { + rw, err := rw() + if err != nil { + return + } + defer rw.Close() + + _, err = io.Copy(w, rw) + if err != nil { + return + } + }) +} diff --git a/models/errors.go b/models/errors.go index 3efadbc..2c4ae68 100644 --- a/models/errors.go +++ b/models/errors.go @@ -8,3 +8,4 @@ var NotFoundError = errors.New("not found") var SiteRequiredError = errors.New("site required") var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds") var SlugConflictError = errors.New("a record with this slug already exists") +var UnsupportedImageFormat = errors.New("unsupported image format") diff --git a/models/imgedit.go b/models/imgedit.go new file mode 100644 index 0000000..88ec8be --- /dev/null +++ b/models/imgedit.go @@ -0,0 +1,59 @@ +package models + +import ( + "crypto/md5" + "encoding/json" + "fmt" + "strings" + "time" +) + +type ImageEditSession struct { + GUID string `json:"guid"` + SiteID int64 `json:"siteId"` + UserID int64 `json:"userId"` + BaseUploadID int64 `json:"baseUploadId"` + ImageExt string `json:"imageExt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Processors []ImageEditProcessor `json:"processors"` +} + +func (ieh ImageEditSession) PreviewURL() string { + return fmt.Sprintf("/sites/%v/imageedit/%v/preview/%v", ieh.SiteID, ieh.GUID, ieh.Processors[len(ieh.Processors)-1].VersionID) +} + +func (ieh *ImageEditSession) RecalcVersionIDs() { + for i, p := range ieh.Processors { + if i == 0 { + p.SetVersionID("") + } else { + p.SetVersionID(ieh.Processors[i-1].VersionID) + } + + ieh.Processors[i] = p + } +} + +type ImageEditProcessor struct { + Type string `json:"type"` + Props json.RawMessage `json:"props"` + + // VersionID is a unique hash of the particular processor. This includes the version ID of the previous processor, + // thereby causing a change of one processor to affect the version IDs of processors down the line. + VersionID string `json:"versionId"` +} + +func (ieh *ImageEditProcessor) SetVersionID(previousVersionID string) { + var sb strings.Builder + sb.WriteString(previousVersionID) + sb.WriteString("-") + sb.WriteString(ieh.Type) + sb.WriteString("-") + sb.WriteString(string(ieh.Props)) + ieh.VersionID = fmt.Sprintf("%x", md5.Sum([]byte(sb.String()))) +} + +type CopyUploadProps struct { + UploadID int64 `json:"uploadId"` +} diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go new file mode 100644 index 0000000..83e58a9 --- /dev/null +++ b/services/imgedit/processing.go @@ -0,0 +1,91 @@ +package imgedit + +import ( + "context" + "encoding/json" + "fmt" + "image" + "os" + "path/filepath" + + "github.com/disintegration/imaging" + "lmika.dev/lmika/weiro/models" +) + +func (s *Service) reprocess(ctx context.Context, session models.ImageEditSession) (imageSource, error) { + var img imageSource + + for _, p := range session.Processors { + // Check if there's currently a cached image of this processor + cachedImageFile := filepath.Join(s.scratchDir, session.GUID, fmt.Sprintf("%v.%v", p.VersionID, session.ImageExt)) + if s, err := os.Stat(cachedImageFile); err == nil && !s.IsDir() { + img = fileImageSource(cachedImageFile) + continue + } + + // Need to process the image + var srcImg image.Image + if img != nil { + var err error + srcImg, err = img.image() + if err != nil { + return nil, err + } + } + + resImg, err := s.processImage(ctx, srcImg, p) + if err != nil { + return nil, err + } + + // Cache the processed image + if err := imaging.Save(resImg, cachedImageFile); err != nil { + return nil, err + } + img = imageImageSource{resImg} + } + + return img, nil +} + +func (s *Service) processImage(ctx context.Context, srcImg image.Image, processor models.ImageEditProcessor) (image.Image, error) { + switch processor.Type { + case "copy-upload": + var p models.CopyUploadProps + if err := json.Unmarshal(processor.Props, &p); err != nil { + return nil, err + } + + _, rc, err := s.uploadService.OpenUpload(ctx, p.UploadID) + if err != nil { + return nil, err + } + + f, err := rc() + if err != nil { + return nil, err + } + defer f.Close() + + return imaging.Decode(f) + } + return nil, fmt.Errorf("unknown processor type: %v", processor.Type) +} + +type imageSource interface { + image() (image.Image, error) +} + +type fileImageSource string + +func (f fileImageSource) image() (image.Image, error) { + return imaging.Open(string(f)) +} + +type imageImageSource struct { + img image.Image +} + +func (i imageImageSource) image() (image.Image, error) { + return i.img, nil +} diff --git a/services/imgedit/service.go b/services/imgedit/service.go new file mode 100644 index 0000000..0b4d080 --- /dev/null +++ b/services/imgedit/service.go @@ -0,0 +1,116 @@ +package imgedit + +import ( + "context" + "encoding/json" + "io" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/uploads" +) + +type Service struct { + scratchDir string + uploadService *uploads.Service + sessionStore *sessionStore +} + +func New( + uploadService *uploads.Service, + scratchDir string, +) *Service { + return &Service{ + scratchDir: scratchDir, + uploadService: uploadService, + sessionStore: &sessionStore{baseDir: scratchDir}, + } +} + +func (s *Service) LoadImageVersion(ctx context.Context, sessionID string, versionID string) (mimeType string, rw func() (io.ReadCloser, error), err error) { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return "", nil, err + } + + session, err := s.sessionStore.get(sessionID) + if err != nil { + return "", nil, err + } else if session.SiteID != site.ID || session.UserID != user.ID { + return "", nil, models.PermissionError + } + + return s.sessionStore.getImage(session, versionID+"."+session.ImageExt) +} + +func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.ImageEditSession, error) { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.ImageEditSession{}, err + } + + upload, _, err := s.uploadService.OpenUpload(ctx, baseUploadID) + if err != nil { + return models.ImageEditSession{}, err + } + + var ext string + switch upload.MIMEType { + case "image/jpeg": + ext = "jpg" + case "image/png": + ext = "png" + default: + return models.ImageEditSession{}, models.UnsupportedImageFormat + } + + newSession := models.ImageEditSession{ + GUID: models.NewNanoID(), + SiteID: site.ID, + UserID: user.ID, + BaseUploadID: baseUploadID, + ImageExt: ext, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + Processors: []models.ImageEditProcessor{ + { + Type: "copy-upload", + Props: mustToJSON(models.CopyUploadProps{UploadID: baseUploadID}), + }, + }, + } + + newSession.RecalcVersionIDs() + if err := s.sessionStore.create(newSession); err != nil { + return models.ImageEditSession{}, err + } + + if _, err := s.reprocess(ctx, newSession); err != nil { + return models.ImageEditSession{}, err + } + + return newSession, nil +} + +func (s *Service) fetchSiteAndUser(ctx context.Context) (models.Site, models.User, error) { + user, ok := models.GetUser(ctx) + if !ok { + return models.Site{}, models.User{}, models.UserRequiredError + } + + site, ok := models.GetSite(ctx) + if !ok { + return models.Site{}, models.User{}, models.SiteRequiredError + } + + if site.OwnerID != user.ID { + return models.Site{}, models.User{}, models.PermissionError + } + + return site, user, nil +} + +func mustToJSON(a any) json.RawMessage { + b, _ := json.Marshal(a) + return b +} diff --git a/services/imgedit/store.go b/services/imgedit/store.go new file mode 100644 index 0000000..b697faa --- /dev/null +++ b/services/imgedit/store.go @@ -0,0 +1,66 @@ +package imgedit + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + + "lmika.dev/lmika/weiro/models" +) + +type sessionStore struct { + baseDir string +} + +func (ss *sessionStore) create(newSession models.ImageEditSession) error { + sessionMeta, err := json.Marshal(newSession) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Join(ss.baseDir, newSession.GUID), 0755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(ss.baseDir, newSession.GUID, "session.json"), sessionMeta, 0644); err != nil { + return err + } + return nil +} + +func (ss *sessionStore) get(guid string) (models.ImageEditSession, error) { + sessionDataBts, err := os.ReadFile(filepath.Join(ss.baseDir, guid, "session.json")) + if err != nil { + return models.ImageEditSession{}, err + } + + sessionData := models.ImageEditSession{} + if err := json.Unmarshal(sessionDataBts, &sessionData); err != nil { + return models.ImageEditSession{}, err + } + + return sessionData, nil +} + +func (ss *sessionStore) getImage(session models.ImageEditSession, imageFilename string) (string, func() (io.ReadCloser, error), error) { + fullPath := filepath.Join(ss.baseDir, session.GUID, imageFilename) + if s, err := os.Stat(fullPath); err != nil { + return "", nil, err + } else if s.IsDir() { + return "", nil, os.ErrNotExist + } + + var mimeType string + switch filepath.Ext(imageFilename) { + case ".jpg", ".jpeg": + mimeType = "image/jpeg" + case ".png": + mimeType = "image/png" + default: + return "", nil, models.UnsupportedImageFormat + } + + return mimeType, func() (io.ReadCloser, error) { + return os.Open(fullPath) + }, nil +} diff --git a/services/services.go b/services/services.go index 852dea3..ab1a4ca 100644 --- a/services/services.go +++ b/services/services.go @@ -8,6 +8,7 @@ import ( "lmika.dev/lmika/weiro/providers/uploadfiles" "lmika.dev/lmika/weiro/services/auth" "lmika.dev/lmika/weiro/services/categories" + "lmika.dev/lmika/weiro/services/imgedit" "lmika.dev/lmika/weiro/services/pages" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" @@ -23,6 +24,7 @@ type Services struct { Posts *posts.Service Sites *sites.Service Uploads *uploads.Service + ImageEdit *imgedit.Service Categories *categories.Service Pages *pages.Service } @@ -41,6 +43,7 @@ func New(cfg config.Config) (*Services, error) { postService := posts.New(dbp, publisherQueue) siteService := sites.New(dbp) uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending")) + imageEditService := imgedit.New(uploadService, filepath.Join(cfg.ScratchDir, "imageedit")) categoriesService := categories.New(dbp, publisherQueue) pagesService := pages.New(dbp, publisherQueue) @@ -52,6 +55,7 @@ func New(cfg config.Config) (*Services, error) { Posts: postService, Sites: siteService, Uploads: uploadService, + ImageEdit: imageEditService, Categories: categoriesService, Pages: pagesService, }, nil diff --git a/views/uploads/edit.html b/views/uploads/edit.html index 18ff35f..3137e00 100644 --- a/views/uploads/edit.html +++ b/views/uploads/edit.html @@ -1,8 +1,12 @@
-
+
- {{ .upload.Upload.Alt }} + {{ .upload.Upload.Alt }}