mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 16:02:10 +00:00
238 lines
7.3 KiB
Go
238 lines
7.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"image"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/oauth"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/storage"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// An app logo is shown to other workspaces on the consent screen and in the
|
|
// directory, so it is only ever an image this instance re-encoded and stored
|
|
// for the app's own workspace.
|
|
|
|
const (
|
|
appLogoPrefix = oauth.AppLogoPrefix
|
|
appLogoMinSide = 32
|
|
// ErrCodeInvalidLogo is the response code for a logo URL this instance did not issue.
|
|
ErrCodeInvalidLogo = "invalid_logo"
|
|
)
|
|
|
|
// reencodeLogo decodes the upload and writes it out again, so what is stored
|
|
// is pixels only: no metadata (camera, location), no trailing bytes another
|
|
// parser could read as something else.
|
|
func reencodeLogo(body []byte) ([]byte, string, string, *errx.Error) {
|
|
img, format, err := image.Decode(bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, "", "", errx.New(errx.BadRequest, "image could not be parsed")
|
|
}
|
|
b := img.Bounds()
|
|
if b.Dx() < appLogoMinSide || b.Dy() < appLogoMinSide {
|
|
return nil, "", "", errx.New(errx.BadRequest, "the logo must be at least 32px on each side")
|
|
}
|
|
var out bytes.Buffer
|
|
switch format {
|
|
case "png":
|
|
if err := png.Encode(&out, img); err != nil {
|
|
return nil, "", "", errx.InternalError()
|
|
}
|
|
return out.Bytes(), "image/png", ".png", nil
|
|
case "jpeg":
|
|
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil {
|
|
return nil, "", "", errx.InternalError()
|
|
}
|
|
return out.Bytes(), "image/jpeg", ".jpg", nil
|
|
}
|
|
return nil, "", "", errx.New(errx.BadRequest, "the logo must be a PNG or JPG")
|
|
}
|
|
|
|
// appOwnLogoPrefix is the key prefix of images stored for one app. Only those
|
|
// are deleted when its logo changes; an older upload another app may share is left alone.
|
|
func appOwnLogoPrefix(appID uuid.UUID) string {
|
|
return appLogoPrefix + appID.String() + "-"
|
|
}
|
|
|
|
// deleteAppLogo removes an app's previous image when it was stored for that app,
|
|
// or is a workspace upload that no other app of the workspace still shows.
|
|
func (h *Handler) deleteAppLogo(ctx context.Context, orgID, appID uuid.UUID, previousURL, keepKey string) {
|
|
if previousURL == "" {
|
|
return
|
|
}
|
|
if avatarKeyFromURL(previousURL, appOwnLogoPrefix(appID)) != "" {
|
|
h.deleteAvatarObject(ctx, previousURL, appOwnLogoPrefix(appID), keepKey)
|
|
return
|
|
}
|
|
orgPrefix := appLogoPrefix + orgID.String() + "/"
|
|
if avatarKeyFromURL(previousURL, orgPrefix) == "" {
|
|
return
|
|
}
|
|
apps, err := h.OAuthService.ListApplications(ctx, orgID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, a := range apps {
|
|
if a.ID != appID && a.LogoURL == previousURL {
|
|
return
|
|
}
|
|
}
|
|
h.deleteAvatarObject(ctx, previousURL, orgPrefix, keepKey)
|
|
}
|
|
|
|
func appLogoKey(orgID uuid.UUID, ext string) (string, error) {
|
|
buf := make([]byte, 12)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return appLogoPrefix + orgID.String() + "/" + hex.EncodeToString(buf) + ext, nil
|
|
}
|
|
|
|
// checkAppLogo accepts an empty logo, the app's current one, or a logo this
|
|
// instance stored for this workspace. Anything else (another workspace's
|
|
// upload, an outside URL that could track who opens the consent screen) is
|
|
// refused.
|
|
func checkAppLogo(ctx context.Context, store storage.Store, orgID uuid.UUID, logoURL, current string) *errx.Error {
|
|
logoURL = strings.TrimSpace(logoURL)
|
|
if logoURL == "" || logoURL == current {
|
|
return nil
|
|
}
|
|
refuse := errx.NewWithIdentifier(errx.BadRequest, ErrCodeInvalidLogo, "upload the logo through Warmbly; other image addresses are not accepted")
|
|
pu, ok := store.(storage.PublicURLer)
|
|
if !ok || store == nil {
|
|
return refuse
|
|
}
|
|
key, ok := oauth.IssuedLogoKey(pu, orgID, logoURL)
|
|
if !ok {
|
|
return refuse
|
|
}
|
|
r, err := store.Get(ctx, key)
|
|
if err != nil {
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
return refuse
|
|
}
|
|
return errx.New(errx.Internal, "logo lookup failed")
|
|
}
|
|
_ = r.Close()
|
|
return nil
|
|
}
|
|
|
|
// hostedAppLogo reports whether logoURL is an app logo this instance stored, read without a fetch.
|
|
func hostedAppLogo(store storage.Store, logoURL string) bool {
|
|
pu, ok := store.(storage.PublicURLer)
|
|
if !ok || store == nil || logoURL == "" {
|
|
return false
|
|
}
|
|
base := pu.PublicURL("")
|
|
if !strings.HasPrefix(logoURL, base) {
|
|
return false
|
|
}
|
|
key := strings.TrimPrefix(logoURL, base)
|
|
if !strings.HasPrefix(key, appLogoPrefix) || strings.Contains(key, "..") || pu.PublicURL(key) != logoURL {
|
|
return false
|
|
}
|
|
return strings.HasSuffix(key, ".png") || strings.HasSuffix(key, ".jpg")
|
|
}
|
|
|
|
// appLogoTarget resolves the workspace and the app it owns for a logo change.
|
|
func (h *Handler) appLogoTarget(c *gin.Context) (uuid.UUID, *models.OAuthApplication, bool) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return uuid.Nil, nil, false
|
|
}
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
|
return uuid.Nil, nil, false
|
|
}
|
|
app, gerr := h.OAuthService.GetApplication(c.Request.Context(), *orgID, id)
|
|
if gerr != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "lookup failed"))
|
|
return uuid.Nil, nil, false
|
|
}
|
|
if app == nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, "application not found"))
|
|
return uuid.Nil, nil, false
|
|
}
|
|
return *orgID, app, true
|
|
}
|
|
|
|
// UploadOAuthApplicationLogo is POST /oauth/applications/:id/logo, the same
|
|
// shape as the workspace logo: the server stores the image and sets it on the
|
|
// app, so no client ever supplies an image address, and the previous image is
|
|
// deleted once nothing points at it.
|
|
func (h *Handler) UploadOAuthApplicationLogo(c *gin.Context) {
|
|
orgID, app, ok := h.appLogoTarget(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if app.SuspendedAt != nil {
|
|
errx.JSON(c, oauthAppWriteError(oauth.ErrAppSuspended))
|
|
return
|
|
}
|
|
userID, _ := middleware.GetUserUUID(c)
|
|
if err := h.OAuthService.CheckDeveloperAccess(c.Request.Context(), orgID, userID); err != nil {
|
|
errx.JSON(c, oauthAppWriteError(err))
|
|
return
|
|
}
|
|
raw, _, _, xerr := readAvatarUpload(c)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
body, mime, ext, xerr := reencodeLogo(raw)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
key := avatarObjectKey(appLogoPrefix, app.ID, ext)
|
|
url, xerr := putPublicObject(ctx, h.Storage, key, body, mime)
|
|
if xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
updated, err := h.OAuthService.SetLogo(ctx, orgID, app.ID, url)
|
|
if err != nil {
|
|
_ = h.Storage.Delete(ctx, key)
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
h.deleteAppLogo(ctx, orgID, app.ID, app.LogoURL, key)
|
|
c.JSON(http.StatusOK, updated)
|
|
}
|
|
|
|
// DeleteOAuthApplicationLogo is DELETE /oauth/applications/:id/logo.
|
|
func (h *Handler) DeleteOAuthApplicationLogo(c *gin.Context) {
|
|
orgID, app, ok := h.appLogoTarget(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if app.SuspendedAt != nil {
|
|
errx.JSON(c, oauthAppWriteError(oauth.ErrAppSuspended))
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
updated, err := h.OAuthService.SetLogo(ctx, orgID, app.ID, "")
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
h.deleteAppLogo(ctx, orgID, app.ID, app.LogoURL, "")
|
|
c.JSON(http.StatusOK, updated)
|
|
}
|