mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-06-08 22:05:12 +00:00
Add logo upload
This commit is contained in:
@@ -34,6 +34,17 @@ TVDB_PIN=
|
||||
# The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance.
|
||||
PUBLIC_URL=http://localhost:8901
|
||||
|
||||
# See OIDC doc for help
|
||||
# OIDC_GOOGLE_NAME=Google
|
||||
# OIDC_GOOGLE_LOGO=https://www.gstatic.com/marketing-cms/assets/images/d5/dc/cfe9ce8b4425b410b49b7f2dd3f3/g.webp=s200
|
||||
# OIDC_GOOGLE_CLIENTID=<client-id> # the client ID you got from Google
|
||||
# OIDC_GOOGLE_SECRET=<client-secret> # the client secret you got from Google
|
||||
# OIDC_GOOGLE_AUTHORIZATION=https://accounts.google.com/o/oauth2/auth
|
||||
# OIDC_GOOGLE_TOKEN=https://oauth2.googleapis.com/token
|
||||
# OIDC_GOOGLE_PROFILE=https://www.googleapis.com/oauth2/v2/userinfo
|
||||
# OIDC_GOOGLE_SCOPE="email openid profile"
|
||||
# OIDC_GOOGLE_AUTHMETHOD=ClientSecretPost
|
||||
|
||||
# Default permissions of new users. They are able to browse & play videos.
|
||||
# Set `verified` to true if you don't wanna manually verify users.
|
||||
EXTRA_CLAIMS='{"permissions": ["core.read", "core.play"], "verified": false}'
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
# path of the private key used to sign jwts. If this is empty, a new one will be generated on startup
|
||||
RSA_PRIVATE_KEY_PATH=""
|
||||
|
||||
PROFILE_PICTURE_PATH="/profile_pictures"
|
||||
|
||||
# json object with the claims to add to every jwt (this is read when creating a new user)
|
||||
EXTRA_CLAIMS='{}'
|
||||
# json object with the claims to add to every jwt of the FIRST user (this can be used to mark the first user as admin).
|
||||
@@ -19,6 +21,19 @@ PROTECTED_CLAIMS="permissions"
|
||||
# The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance.
|
||||
PUBLIC_URL=http://localhost:8901
|
||||
|
||||
|
||||
# See OIDC doc for help
|
||||
# OIDC_GOOGLE_NAME=Google
|
||||
# OIDC_GOOGLE_LOGO=https://www.gstatic.com/marketing-cms/assets/images/d5/dc/cfe9ce8b4425b410b49b7f2dd3f3/g.webp=s200
|
||||
# OIDC_GOOGLE_CLIENTID=<client-id> # the client ID you got from Google
|
||||
# OIDC_GOOGLE_SECRET=<client-secret> # the client secret you got from Google
|
||||
# OIDC_GOOGLE_AUTHORIZATION=https://accounts.google.com/o/oauth2/auth
|
||||
# OIDC_GOOGLE_TOKEN=https://oauth2.googleapis.com/token
|
||||
# OIDC_GOOGLE_PROFILE=https://www.googleapis.com/oauth2/v2/userinfo
|
||||
# OIDC_GOOGLE_SCOPE="email openid profile"
|
||||
# OIDC_GOOGLE_AUTHMETHOD=ClientSecretPost
|
||||
|
||||
|
||||
# You can create apikeys at runtime via POST /key but you can also have some defined in the env.
|
||||
# Replace $YOURNAME with the name of the key you want (only alpha are valid)
|
||||
# The value will be the apikey (max 128 bytes)
|
||||
|
||||
+17
-11
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
@@ -23,17 +24,18 @@ import (
|
||||
)
|
||||
|
||||
type Configuration struct {
|
||||
JwtPrivateKey *rsa.PrivateKey
|
||||
JwtPublicKey *rsa.PublicKey
|
||||
JwtKid string
|
||||
PublicUrl string
|
||||
OidcProviders map[string]OidcProviderConfig
|
||||
DefaultClaims jwt.MapClaims
|
||||
FirstUserClaims jwt.MapClaims
|
||||
GuestClaims jwt.MapClaims
|
||||
ProtectedClaims []string
|
||||
ExpirationDelay time.Duration
|
||||
EnvApiKeys []ApiKeyWToken
|
||||
JwtPrivateKey *rsa.PrivateKey
|
||||
JwtPublicKey *rsa.PublicKey
|
||||
JwtKid string
|
||||
PublicUrl string
|
||||
OidcProviders map[string]OidcProviderConfig
|
||||
DefaultClaims jwt.MapClaims
|
||||
FirstUserClaims jwt.MapClaims
|
||||
GuestClaims jwt.MapClaims
|
||||
ProtectedClaims []string
|
||||
ExpirationDelay time.Duration
|
||||
EnvApiKeys []ApiKeyWToken
|
||||
ProfilePicturePath string
|
||||
}
|
||||
|
||||
type OidcAuthMethod string
|
||||
@@ -69,6 +71,10 @@ func LoadConfiguration(ctx context.Context, db *dbc.Queries) (*Configuration, er
|
||||
ret := DefaultConfig
|
||||
|
||||
ret.PublicUrl = os.Getenv("PUBLIC_URL")
|
||||
ret.ProfilePicturePath = cmp.Or(
|
||||
os.Getenv("PROFILE_PICTURE_PATH"),
|
||||
"/profile_pictures",
|
||||
)
|
||||
|
||||
claims := os.Getenv("EXTRA_CLAIMS")
|
||||
if claims != "" {
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"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) 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)
|
||||
}
|
||||
@@ -368,6 +368,8 @@ func main() {
|
||||
r.GET("/users/:id", h.GetUser)
|
||||
r.GET("/users/me", h.GetMe)
|
||||
r.GET("/users/me/logo", h.GetMyLogo)
|
||||
r.POST("/users/me/logo", h.UploadMyLogo)
|
||||
r.DELETE("/users/me/logo", h.DeleteMyLogo)
|
||||
r.GET("/users/:id/logo", h.GetUserLogo)
|
||||
r.DELETE("/users/:id", h.DeleteUser)
|
||||
r.DELETE("/users/me", h.DeleteSelf)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
"github.com/google/uuid"
|
||||
@@ -153,97 +150,6 @@ func (h *Handler) GetMe(c *echo.Context) error {
|
||||
return c.JSON(200, ret)
|
||||
}
|
||||
|
||||
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 gravatar image
|
||||
// @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
|
||||
}
|
||||
|
||||
users, err := h.db.GetUser(ctx, dbc.GetUserParams{
|
||||
UseId: true,
|
||||
Id: id,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.streamGravatar(c, users.User.Email)
|
||||
}
|
||||
|
||||
// @Summary Get user logo
|
||||
// @Description Get a user's gravatar image
|
||||
// @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)
|
||||
users, 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
|
||||
}
|
||||
|
||||
return h.streamGravatar(c, users.User.Email)
|
||||
}
|
||||
|
||||
// @Summary Register
|
||||
// @Description Register as a new user and open a session for it
|
||||
// @Tags users
|
||||
|
||||
@@ -37,6 +37,13 @@ Create kyoo auth name
|
||||
{{- printf "%s-%s" (include "kyoo.fullname" .) .Values.auth.name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create kyoo auth-profile-pictures name
|
||||
*/}}
|
||||
{{- define "kyoo.authprofilepictures.fullname" -}}
|
||||
{{- printf "%s-%s%s" (include "kyoo.fullname" .) .Values.auth.name "profile-pictures" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create the name of the auth service account to use
|
||||
*/}}
|
||||
@@ -142,4 +149,4 @@ Create kyoo postgres base host
|
||||
*/}}
|
||||
{{- define "kyoo.postgres.shared.host" -}}
|
||||
{{- default (printf "%s-postgres" (include "kyoo.fullname" .)) .Values.global.postgres.shared.host -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
@@ -157,8 +157,12 @@ spec:
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.global.extraVolumeMounts .Values.auth.kyoo_auth.extraVolumeMounts .Values.kyoo.auth.privatekey.existingSecret }}
|
||||
{{- if or .Values.global.extraVolumeMounts .Values.auth.kyoo_auth.extraVolumeMounts .Values.kyoo.auth.privatekey.existingSecret .Values.auth.persistence.enabled }}
|
||||
volumeMounts:
|
||||
{{- if .Values.auth.persistence.enabled }}
|
||||
- name: profilepictures
|
||||
mountPath: /profile_pictures
|
||||
{{- end }}
|
||||
{{- with .Values.global.extraVolumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -178,8 +182,19 @@ spec:
|
||||
initContainers:
|
||||
{{- tpl (toYaml .) $ | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- if or .Values.global.extraVolumes .Values.auth.extraVolumes .Values.kyoo.auth.privatekey.existingSecret }}
|
||||
{{- if or .Values.global.extraVolumes .Values.auth.extraVolumes .Values.kyoo.auth.privatekey.existingSecret .Values.auth.persistence.enabled }}
|
||||
volumes:
|
||||
{{- if .Values.auth.persistence.enabled }}
|
||||
{{- if .Values.auth.persistence.existingClaim }}
|
||||
- name: profilepictures
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ .Values.auth.persistence.existingClaim }}
|
||||
{{- else }}
|
||||
- name: profilepictures
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "kyoo.authprofilepictures.fullname" . }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.global.extraVolumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{{- if and .Values.auth.persistence.enabled (not .Values.auth.persistence.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "kyoo.authprofilepictures.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "kyoo.labels" (dict "context" . "component" .Values.auth.name "name" .Values.auth.name) | nindent 4 }}
|
||||
{{- with (mergeOverwrite (deepCopy .Values.global.persistentVolumeClaimAnnotations) .Values.auth.persistence.annotations) }}
|
||||
annotations:
|
||||
{{- range $key, $value := . }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- range .Values.auth.persistence.accessModes }}
|
||||
- {{ . }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.auth.persistence.size }}
|
||||
{{- if .Values.auth.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.auth.persistence.storageClass }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -278,6 +278,15 @@ auth:
|
||||
extraContainers: []
|
||||
extraInitContainers: []
|
||||
extraVolumes: []
|
||||
# profile pictures of users
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 500Mi
|
||||
annotations: {}
|
||||
storageClass: ""
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
existingClaim: ""
|
||||
|
||||
# front deployment configuration
|
||||
front:
|
||||
|
||||
@@ -64,6 +64,8 @@ services:
|
||||
- "4568:4568"
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- profile_pictures:/profile_pictures
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.auth.rule=PathPrefix(`/auth/`) || PathPrefix(`/.well-known/`)"
|
||||
@@ -213,4 +215,5 @@ services:
|
||||
volumes:
|
||||
db:
|
||||
images:
|
||||
profile_pictures:
|
||||
transcoder_metadata:
|
||||
|
||||
@@ -43,6 +43,8 @@ services:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- profile_pictures:/profile_pictures
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.auth.rule=PathPrefix(`/auth/`) || PathPrefix(`/.well-known/`)"
|
||||
@@ -159,4 +161,5 @@ services:
|
||||
volumes:
|
||||
db:
|
||||
images:
|
||||
profile_pictures:
|
||||
transcoder_metadata:
|
||||
|
||||
@@ -107,6 +107,13 @@ export const expo: ExpoConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
cameraPermission: false,
|
||||
microphonePermission: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"expo-dev-client": "~55.0.18",
|
||||
"expo-font": "^55.0.4",
|
||||
"expo-image": "~55.0.6",
|
||||
"expo-image-picker": "^55.0.13",
|
||||
"expo-linear-gradient": "~55.0.9",
|
||||
"expo-linking": "~55.0.8",
|
||||
"expo-localization": "~55.0.9",
|
||||
@@ -901,6 +902,10 @@
|
||||
|
||||
"expo-image": ["expo-image@55.0.6", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-TKuu0uBmgTZlhd91Glv+V4vSBMlfl0bdQxfl97oKKZUo3OBC13l3eLik7v3VNLJN7PZbiwOAiXkZkqSOBx/Xsw=="],
|
||||
|
||||
"expo-image-loader": ["expo-image-loader@55.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ=="],
|
||||
|
||||
"expo-image-picker": ["expo-image-picker@55.0.13", "", { "dependencies": { "expo-image-loader": "~55.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-G+W11rcoUi3rK+6cnKWkTfZilMkGVZnYe90TiM3R98nPSlzGBoto3a/TkGGTJXedz/dmMzr49L+STlWhuKKIFw=="],
|
||||
|
||||
"expo-json-utils": ["expo-json-utils@55.0.0", "", {}, "sha512-aupt/o5PDAb8dXDCb0JcRdkqnTLxe/F+La7jrnyd/sXlYFfRgBJLFOa1SqVFXm1E/Xam1SE/yw6eAb+DGY7Arg=="],
|
||||
|
||||
"expo-keep-awake": ["expo-keep-awake@55.0.4", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-vwfdMtMS5Fxaon8gC0AiE70SpxTsHJ+rjeoVJl8kdfdbxczF7OIaVmfjFJ5Gfigd/WZiLqxhfZk34VAkXF4PNg=="],
|
||||
|
||||
+1
-1
@@ -38,6 +38,7 @@
|
||||
"expo-dev-client": "~55.0.18",
|
||||
"expo-font": "^55.0.4",
|
||||
"expo-image": "~55.0.6",
|
||||
"expo-image-picker": "^55.0.13",
|
||||
"expo-linear-gradient": "~55.0.9",
|
||||
"expo-linking": "~55.0.8",
|
||||
"expo-localization": "~55.0.9",
|
||||
@@ -68,7 +69,6 @@
|
||||
"react-native-worklets": "0.7.2",
|
||||
"react-tooltip": "^5.30.0",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
|
||||
@@ -41,17 +41,12 @@ export const queryFn = async <Parser extends z.ZodTypeAny>(context: {
|
||||
try {
|
||||
resp = await fetch(context.url, {
|
||||
method: context.method,
|
||||
body:
|
||||
"body" in context && context.body
|
||||
? JSON.stringify(context.body)
|
||||
: "formData" in context && context.formData
|
||||
? context.formData
|
||||
: undefined,
|
||||
body: context.body ? JSON.stringify(context.body) : context.formData,
|
||||
headers: {
|
||||
...(context.authToken
|
||||
? { Authorization: `Bearer ${context.authToken}` }
|
||||
: {}),
|
||||
...("body" in context ? { "Content-Type": "application/json" } : {}),
|
||||
...(context.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
signal: context.signal,
|
||||
});
|
||||
@@ -319,6 +314,7 @@ type MutationParams = {
|
||||
[query: string]: boolean | number | string | string[] | undefined;
|
||||
};
|
||||
body?: object;
|
||||
formData?: FormData;
|
||||
};
|
||||
|
||||
export const useMutation = <T = void, QueryRet = void>({
|
||||
@@ -337,7 +333,7 @@ export const useMutation = <T = void, QueryRet = void>({
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useRQMutation({
|
||||
mutationFn: (param: T) => {
|
||||
const { method, path, params, body } = {
|
||||
const { method, path, params, body, formData } = {
|
||||
...queryParams,
|
||||
...compute?.(param),
|
||||
} as Required<MutationParams>;
|
||||
@@ -346,6 +342,7 @@ export const useMutation = <T = void, QueryRet = void>({
|
||||
method,
|
||||
url: keyToUrl(toQueryKey({ apiUrl, path, params })),
|
||||
body,
|
||||
formData,
|
||||
authToken,
|
||||
parser: null,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Username from "@material-symbols/svg-400/outlined/badge.svg";
|
||||
import Mail from "@material-symbols/svg-400/outlined/mail.svg";
|
||||
import Password from "@material-symbols/svg-400/outlined/password.svg";
|
||||
// import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
|
||||
import AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
|
||||
import Delete from "@material-symbols/svg-400/rounded/delete.svg";
|
||||
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
|
||||
// import * as ImagePicker from "expo-image-picker";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
@@ -12,6 +12,7 @@ import { useUniwind } from "uniwind";
|
||||
import type { KyooError, User } from "~/models";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
type Icon,
|
||||
Input,
|
||||
@@ -25,16 +26,6 @@ import { deleteAccount, logout } from "../login/logic";
|
||||
import { PasswordInput } from "../login/password-input";
|
||||
import { Preference, SettingsContainer } from "./base";
|
||||
|
||||
// function dataURItoBlob(dataURI: string) {
|
||||
// const byteString = atob(dataURI.split(",")[1]);
|
||||
// const ab = new ArrayBuffer(byteString.length);
|
||||
// const ia = new Uint8Array(ab);
|
||||
// for (let i = 0; i < byteString.length; i++) {
|
||||
// ia[i] = byteString.charCodeAt(i);
|
||||
// }
|
||||
// return new Blob([ab], { type: "image/jpeg" });
|
||||
// }
|
||||
|
||||
export const AccountSettings = () => {
|
||||
const account = useAccount()!;
|
||||
const { theme } = useUniwind();
|
||||
@@ -62,6 +53,15 @@ export const AccountSettings = () => {
|
||||
invalidate: ["auth", "users", "me"],
|
||||
});
|
||||
|
||||
const { mutateAsync: editLogo } = useMutation({
|
||||
path: ["auth", "users", "me", "logo"],
|
||||
compute: (formData: FormData | null) => ({
|
||||
method: formData ? "POST" : "DELETE",
|
||||
formData: formData ?? undefined,
|
||||
}),
|
||||
invalidate: null,
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title={t("settings.account.label")}
|
||||
@@ -120,42 +120,41 @@ export const AccountSettings = () => {
|
||||
}
|
||||
/>
|
||||
</Preference>
|
||||
{/* <Preference */}
|
||||
{/* icon={AccountCircle} */}
|
||||
{/* customIcon={<Avatar src={account.logo} />} */}
|
||||
{/* label={t("settings.account.avatar.label")} */}
|
||||
{/* description={t("settings.account.avatar.description")} */}
|
||||
{/* > */}
|
||||
{/* <Button */}
|
||||
{/* text={t("misc.edit")} */}
|
||||
{/* onPress={async () => { */}
|
||||
{/* const img = await ImagePicker.launchImageLibraryAsync({ */}
|
||||
{/* mediaTypes: ImagePicker.MediaTypeOptions.Images, */}
|
||||
{/* aspect: [1, 1], */}
|
||||
{/* quality: 1, */}
|
||||
{/* base64: true, */}
|
||||
{/* }); */}
|
||||
{/* if (img.canceled || img.assets.length !== 1) return; */}
|
||||
{/* const data = dataURItoBlob(img.assets[0].uri); */}
|
||||
{/* const formData = new FormData(); */}
|
||||
{/* formData.append("picture", data); */}
|
||||
{/* await queryFn({ */}
|
||||
{/* method: "POST", */}
|
||||
{/* path: ["auth", "me", "logo"], */}
|
||||
{/* formData, */}
|
||||
{/* }); */}
|
||||
{/* }} */}
|
||||
{/* /> */}
|
||||
{/* <Button */}
|
||||
{/* text={t("misc.delete")} */}
|
||||
{/* onPress={async () => { */}
|
||||
{/* await queryFn({ */}
|
||||
{/* method: "DELETE", */}
|
||||
{/* path: ["auth", "me", "logo"], */}
|
||||
{/* }); */}
|
||||
{/* }} */}
|
||||
{/* /> */}
|
||||
{/* </Preference> */}
|
||||
<Preference
|
||||
icon={AccountCircle}
|
||||
customIcon={<Avatar src={account.logo} />}
|
||||
label={t("settings.account.avatar.label")}
|
||||
description={t("settings.account.avatar.description")}
|
||||
>
|
||||
<Button
|
||||
text={t("misc.edit")}
|
||||
onPress={async () => {
|
||||
const img = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: "images",
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
shape: "oval",
|
||||
quality: 0,
|
||||
base64: true,
|
||||
});
|
||||
if (img.canceled || img.assets.length !== 1) return;
|
||||
const response = await fetch(img.assets[0].uri);
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"logo",
|
||||
await response.blob(),
|
||||
img.assets[0].fileName ?? "logo.jpg",
|
||||
);
|
||||
await editLogo(formData);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
text={t("misc.delete")}
|
||||
onPress={async () => {
|
||||
await editLogo(null);
|
||||
}}
|
||||
/>
|
||||
</Preference>
|
||||
<Preference
|
||||
icon={Mail}
|
||||
label={t("settings.account.email.label")}
|
||||
|
||||
Reference in New Issue
Block a user