Kyoo/auth/logo.go
2026-03-26 23:46:11 +01:00

338 lines
8.9 KiB
Go

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)
}