Add logo upload

This commit is contained in:
2026-03-26 13:07:57 +01:00
parent 0a4f48e4ee
commit 8df5f279a8
17 changed files with 481 additions and 165 deletions
+11
View File
@@ -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}'
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+2
View File
@@ -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)
-94
View File
@@ -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
+8 -1
View File
@@ -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 -}}
+17 -2
View File
@@ -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 }}
+26
View File
@@ -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 }}
+9
View File
@@ -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:
+3
View File
@@ -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:
+3
View File
@@ -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:
+7
View File
@@ -107,6 +107,13 @@ export const expo: ExpoConfig = {
},
},
],
[
"expo-image-picker",
{
cameraPermission: false,
microphonePermission: false,
},
],
],
experiments: {
typedRoutes: true,
+5
View File
@@ -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
View File
@@ -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",
+5 -8
View File
@@ -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,
});
+47 -48
View File
@@ -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")}