mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-28 04:17:50 -04:00
338 lines
8.9 KiB
Go
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)
|
|
}
|