package main import ( "context" "crypto/md5" "encoding/hex" "errors" "fmt" "io" "net/http" "os" "path/filepath" "slices" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/labstack/echo/v5" "github.com/zoriya/kyoo/keibi/dbc" ) const maxLogoSize = 5 << 20 var allowedLogoTypes = []string{ "image/jpeg", "image/png", "image/gif", "image/webp", } func (h *Handler) logoPath(id uuid.UUID) string { return filepath.Join(h.config.ProfilePicturePath, id.String()) } func (h *Handler) streamManualLogo(c *echo.Context, id uuid.UUID) error { file, err := os.Open(h.logoPath(id)) if err != nil { if os.IsNotExist(err) { return echo.NewHTTPError(http.StatusNotFound, "No manual logo found") } return err } defer file.Close() header := make([]byte, 512) n, err := file.Read(header) if err != nil && err != io.EOF { return err } if _, err := file.Seek(0, io.SeekStart); err != nil { return err } contentType := http.DetectContentType(header[:n]) return c.Stream(http.StatusOK, contentType, file) } func (h *Handler) writeManualLogo(id uuid.UUID, data []byte) error { if err := os.MkdirAll(h.config.ProfilePicturePath, 0o755); err != nil { return err } tmpFile, err := os.CreateTemp(h.config.ProfilePicturePath, id.String()+"-*.tmp") if err != nil { return err } tmpPath := tmpFile.Name() if _, err := tmpFile.Write(data); err != nil { tmpFile.Close() os.Remove(tmpPath) return err } if err := tmpFile.Close(); err != nil { os.Remove(tmpPath) return err } if err := os.Rename(tmpPath, h.logoPath(id)); err != nil { os.Remove(tmpPath) return err } return nil } func (h *Handler) downloadLogo(ctx context.Context, id uuid.UUID, logoURL string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, logoURL, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected logo response status: %d", resp.StatusCode) } data, err := io.ReadAll(io.LimitReader(resp.Body, maxLogoSize+1)) if err != nil { return err } if len(data) > maxLogoSize { return fmt.Errorf("logo file too large") } if !slices.Contains(allowedLogoTypes, http.DetectContentType(data)) { return fmt.Errorf("unsupported logo content type") } return h.writeManualLogo(id, data) } func (h *Handler) streamGravatar(c *echo.Context, email string) error { hash := md5.Sum([]byte(strings.TrimSpace(strings.ToLower(email)))) url := fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=404", hex.EncodeToString(hash[:])) req, err := http.NewRequestWithContext(c.Request().Context(), http.MethodGet, url, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return echo.NewHTTPError(http.StatusBadGateway, "Could not fetch gravatar image") } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return echo.NewHTTPError(http.StatusNotFound, "No gravatar image found for this user") } if resp.StatusCode != http.StatusOK { return echo.NewHTTPError(http.StatusBadGateway, "Could not fetch gravatar image") } contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } return c.Stream(http.StatusOK, contentType, resp.Body) } // @Summary Get my logo // @Description Get the current user's logo (manual upload if available, gravatar otherwise) // @Tags users // @Produce image/* // @Security Jwt // @Success 200 {file} binary // @Failure 401 {object} KError "Missing jwt token" // @Failure 403 {object} KError "Invalid jwt token (or expired)" // @Failure 404 {object} KError "No gravatar image found for this user" // @Router /users/me/logo [get] func (h *Handler) GetMyLogo(c *echo.Context) error { ctx := c.Request().Context() id, err := GetCurrentUserId(c) if err != nil { return err } if err := h.streamManualLogo(c, id); err == nil { return nil } else if httpErr, ok := err.(*echo.HTTPError); !ok || httpErr.Code != http.StatusNotFound { return err } user, err := h.db.GetUser(ctx, dbc.GetUserParams{ UseId: true, Id: id, }) if err != nil { return err } return h.streamGravatar(c, user.User.Email) } // @Summary Get user logo // @Description Get a user's logo (manual upload if available, gravatar otherwise) // @Tags users // @Produce image/* // @Security Jwt[users.read] // @Param id path string true "The id or username of the user" // @Success 200 {file} binary // @Failure 404 {object} KError "No user found with id or username" // @Failure 404 {object} KError "No gravatar image found for this user" // @Router /users/{id}/logo [get] func (h *Handler) GetUserLogo(c *echo.Context) error { ctx := c.Request().Context() err := CheckPermissions(c, []string{"users.read"}) if err != nil { return err } id := c.Param("id") uid, err := uuid.Parse(id) user, err := h.db.GetUser(ctx, dbc.GetUserParams{ UseId: err == nil, Id: uid, Username: id, }) if err == pgx.ErrNoRows { return echo.NewHTTPError(404, fmt.Sprintf("No user found with id or username: '%s'.", id)) } else if err != nil { return err } if err := h.streamManualLogo(c, user.User.Id); err == nil { return nil } else if httpErr, ok := err.(*echo.HTTPError); !ok || httpErr.Code != http.StatusNotFound { return err } return h.streamGravatar(c, user.User.Email) } // @Summary Upload my logo // @Description Upload a manual profile picture for the current user // @Tags users // @Accept multipart/form-data // @Produce json // @Security Jwt // @Param logo formData file true "Profile picture image (jpeg/png/gif/webp, max 5MB)" // @Success 204 // @Failure 401 {object} KError "Missing jwt token" // @Failure 403 {object} KError "Invalid jwt token (or expired)" // @Failure 413 {object} KError "File too large" // @Failure 422 {object} KError "Missing or invalid logo file" // @Router /users/me/logo [post] func (h *Handler) UploadMyLogo(c *echo.Context) error { id, err := GetCurrentUserId(c) if err != nil { return err } fileHeader, err := c.FormFile("logo") if err != nil { return echo.NewHTTPError(http.StatusUnprocessableEntity, "Missing form file `logo`") } if fileHeader.Size > maxLogoSize { return echo.NewHTTPError(http.StatusRequestEntityTooLarge, "File too large") } file, err := fileHeader.Open() if err != nil { return err } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, maxLogoSize+1)) if err != nil { return err } if len(data) > maxLogoSize { return echo.NewHTTPError(http.StatusRequestEntityTooLarge, "File too large") } if !slices.Contains(allowedLogoTypes, http.DetectContentType(data)) { return echo.NewHTTPError(http.StatusUnprocessableEntity, "Only jpeg, png, gif or webp images are allowed") } if err := h.writeManualLogo(id, data); err != nil { return err } return c.NoContent(http.StatusNoContent) } // @Summary Delete my logo // @Description Delete the current user's manually uploaded profile picture // @Tags users // @Produce json // @Security Jwt // @Success 204 // @Failure 401 {object} KError "Missing jwt token" // @Failure 403 {object} KError "Invalid jwt token (or expired)" // @Router /users/me/logo [delete] func (h *Handler) DeleteMyLogo(c *echo.Context) error { id, err := GetCurrentUserId(c) if err != nil { return err } err = os.Remove(h.logoPath(id)) if errors.Is(err, os.ErrNotExist) { return echo.NewHTTPError( 404, "User does not have a custom profile picture.", ) } else if err != nil { return err } return c.NoContent(http.StatusNoContent) } // @Summary Delete user logo // @Description Delete the user's manually uploaded profile picture // @Tags users // @Produce json // @Security Jwt // @Success 204 // @Param id path string true "The id or username of the user" // @Failure 401 {object} KError "Missing jwt token" // @Failure 403 {object} KError "Invalid jwt token (or expired)" // @Router /users/me/{id} [delete] func (h *Handler) DeleteUserLogo(c *echo.Context) error { ctx := c.Request().Context() err := CheckPermissions(c, []string{"users.write"}) if err != nil { return err } id := c.Param("id") uid, err := uuid.Parse(id) user, err := h.db.GetUser(ctx, dbc.GetUserParams{ UseId: err == nil, Id: uid, Username: id, }) if err == pgx.ErrNoRows { return echo.NewHTTPError(404, fmt.Sprintf("No user found with id or username: '%s'.", id)) } else if err != nil { return err } err = os.Remove(h.logoPath(user.User.Id)) if errors.Is(err, os.ErrNotExist) { return echo.NewHTTPError( 404, "User does not have a custom profile picture.", ) } else if err != nil { return err } return c.NoContent(http.StatusNoContent) }