From 8df5f279a8ae27c0951c1dce41c5298a5dba8cb9 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 26 Mar 2026 13:07:57 +0100 Subject: [PATCH 1/7] Add logo upload --- .env.example | 11 + auth/.env.example | 15 ++ auth/config.go | 28 ++- auth/logo.go | 305 +++++++++++++++++++++++++++ auth/main.go | 2 + auth/users.go | 94 --------- chart/templates/_helpers.tpl | 9 +- chart/templates/auth/deployment.yaml | 19 +- chart/templates/auth/pvc.yaml | 26 +++ chart/values.yaml | 9 + docker-compose.dev.yml | 3 + docker-compose.yml | 3 + front/app.config.ts | 7 + front/bun.lock | 5 + front/package.json | 2 +- front/src/query/query.tsx | 13 +- front/src/ui/settings/account.tsx | 95 +++++---- 17 files changed, 481 insertions(+), 165 deletions(-) create mode 100644 auth/logo.go create mode 100644 chart/templates/auth/pvc.yaml diff --git a/.env.example b/.env.example index 408556d7..d33e5dca 100644 --- a/.env.example +++ b/.env.example @@ -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= # the client ID you got from Google +# OIDC_GOOGLE_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}' diff --git a/auth/.env.example b/auth/.env.example index adae6f80..581d1bad 100644 --- a/auth/.env.example +++ b/auth/.env.example @@ -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= # the client ID you got from Google +# OIDC_GOOGLE_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) diff --git a/auth/config.go b/auth/config.go index 09ba344d..473bdb4d 100644 --- a/auth/config.go +++ b/auth/config.go @@ -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 != "" { diff --git a/auth/logo.go b/auth/logo.go new file mode 100644 index 00000000..924511c9 --- /dev/null +++ b/auth/logo.go @@ -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) +} diff --git a/auth/main.go b/auth/main.go index 2271f184..d4009b7b 100644 --- a/auth/main.go +++ b/auth/main.go @@ -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) diff --git a/auth/users.go b/auth/users.go index 5a0ff5f2..b59f2457 100644 --- a/auth/users.go +++ b/auth/users.go @@ -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 diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl index 23186793..1f5be66a 100644 --- a/chart/templates/_helpers.tpl +++ b/chart/templates/_helpers.tpl @@ -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 -}} \ No newline at end of file +{{- end -}} diff --git a/chart/templates/auth/deployment.yaml b/chart/templates/auth/deployment.yaml index 29fa5e2f..61c33e7f 100644 --- a/chart/templates/auth/deployment.yaml +++ b/chart/templates/auth/deployment.yaml @@ -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 }} diff --git a/chart/templates/auth/pvc.yaml b/chart/templates/auth/pvc.yaml new file mode 100644 index 00000000..bf55fcd5 --- /dev/null +++ b/chart/templates/auth/pvc.yaml @@ -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 }} diff --git a/chart/values.yaml b/chart/values.yaml index eb4d919d..c8be039e 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -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: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fa3124e6..a6fc042e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 8297667d..626929f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/front/app.config.ts b/front/app.config.ts index dcf3e2c7..da47565a 100644 --- a/front/app.config.ts +++ b/front/app.config.ts @@ -107,6 +107,13 @@ export const expo: ExpoConfig = { }, }, ], + [ + "expo-image-picker", + { + cameraPermission: false, + microphonePermission: false, + }, + ], ], experiments: { typedRoutes: true, diff --git a/front/bun.lock b/front/bun.lock index f0689d12..fc4a27d0 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -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=="], diff --git a/front/package.json b/front/package.json index 27ec69cc..b4fd28b6 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/src/query/query.tsx b/front/src/query/query.tsx index 14ec37eb..c41ae2af 100644 --- a/front/src/query/query.tsx +++ b/front/src/query/query.tsx @@ -41,17 +41,12 @@ export const queryFn = async (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 = ({ @@ -337,7 +333,7 @@ export const useMutation = ({ 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; @@ -346,6 +342,7 @@ export const useMutation = ({ method, url: keyToUrl(toQueryKey({ apiUrl, path, params })), body, + formData, authToken, parser: null, }); diff --git a/front/src/ui/settings/account.tsx b/front/src/ui/settings/account.tsx index 9f1458c6..c99d1664 100644 --- a/front/src/ui/settings/account.tsx +++ b/front/src/ui/settings/account.tsx @@ -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 ( { } /> - {/* } */} - {/* label={t("settings.account.avatar.label")} */} - {/* description={t("settings.account.avatar.description")} */} - {/* > */} - {/* ))} - {or} + )} /> diff --git a/front/src/ui/navbar.tsx b/front/src/ui/navbar.tsx index 91096e95..0bd97f05 100644 --- a/front/src/ui/navbar.tsx +++ b/front/src/ui/navbar.tsx @@ -35,6 +35,7 @@ import { A, Avatar, HR, + HRP, IconButton, Link, Menu, @@ -59,12 +60,6 @@ export const NavbarLeft = () => { > {t("navbar.browse")} - - {t("admin.unmatched.label")} - ); }; @@ -258,6 +253,17 @@ export const NavbarProfile = () => { /> )} + {account?.isAdmin && ( + <> + + + + + )} ); }; diff --git a/front/src/ui/settings/oidc.tsx b/front/src/ui/settings/oidc.tsx index dc330800..35f052df 100644 --- a/front/src/ui/settings/oidc.tsx +++ b/front/src/ui/settings/oidc.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Image } from "react-native"; import { type KyooError, User } from "~/models"; +import { AuthInfo } from "~/models/auth-info"; import { Button, IconButton, Link, P, Skeleton, tooltip } from "~/primitives"; import { type QueryIdentifier, useFetch, useMutation } from "~/query"; import { Preference, SettingsContainer } from "./base";