diff --git a/electron.vite.config.1726264954825.mjs b/electron.vite.config.1726264954825.mjs index aa9802ff..ed758ce8 100644 --- a/electron.vite.config.1726264954825.mjs +++ b/electron.vite.config.1726264954825.mjs @@ -4,7 +4,7 @@ import { defineConfig, loadEnv, swcPlugin, - externalizeDepsPlugin + externalizeDepsPlugin, } from "electron-vite"; import react from "@vitejs/plugin-react"; import { sentryVitePlugin } from "@sentry/vite-plugin"; @@ -13,7 +13,7 @@ import svgr from "vite-plugin-svgr"; var sentryPlugin = sentryVitePlugin({ authToken: process.env.SENTRY_AUTH_TOKEN, org: "hydra-launcher", - project: "hydra-launcher" + project: "hydra-launcher", }); var electron_vite_config_default = defineConfig(({ mode }) => { loadEnv(mode); @@ -22,37 +22,35 @@ var electron_vite_config_default = defineConfig(({ mode }) => { build: { sourcemap: true, rollupOptions: { - external: ["better-sqlite3"] - } + external: ["better-sqlite3"], + }, }, resolve: { alias: { "@main": resolve("src/main"), "@locales": resolve("src/locales"), "@resources": resolve("resources"), - "@shared": resolve("src/shared") - } + "@shared": resolve("src/shared"), + }, }, - plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin] + plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin], }, preload: { - plugins: [externalizeDepsPlugin()] + plugins: [externalizeDepsPlugin()], }, renderer: { build: { - sourcemap: true + sourcemap: true, }, resolve: { alias: { "@renderer": resolve("src/renderer/src"), "@locales": resolve("src/locales"), - "@shared": resolve("src/shared") - } + "@shared": resolve("src/shared"), + }, }, - plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin] - } + plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin], + }, }; }); -export { - electron_vite_config_default as default -}; +export { electron_vite_config_default as default }; diff --git a/package.json b/package.json index 8bc189a1..fc23b92b 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "electron-log": "^5.1.4", "electron-updater": "^6.1.8", "fetch-cookie": "^3.0.1", - "file-type": "^19.0.0", "flexsearch": "^0.7.43", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", diff --git a/requirements.txt b/requirements.txt index b1488003..3685495b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ cx_Logging; sys_platform == 'win32' lief; sys_platform == 'win32' pywin32; sys_platform == 'win32' psutil +Pillow diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 2cbbfb78..0055a8b3 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -286,6 +286,10 @@ "friend_code_copied": "Friend code copied", "undo_friendship_modal_text": "This will undo your friendship with {{displayName}}", "privacy_hint": "To adjust who can see this, go to the <0>Settings", - "locked_profile": "This profile is private" + "locked_profile": "This profile is private", + "image_process_failure": "Failure while processing the image", + "required_field": "This field is required", + "displayname_min_length": "Display name must be at least 3 characters long", + "displayname_max_length": "Display name must be at most 50 characters long" } } diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 87b7a3bc..efd430ff 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -289,6 +289,10 @@ "friend_code_copied": "Código de amigo copiado", "undo_friendship_modal_text": "Isso irá remover sua amizade com {{displayName}}", "privacy_hint": "Pra controlar quem pode ver seu perfil, acesse a <0>Tela de Configurações", - "profile_locked": "Este perfil é privado" + "profile_locked": "Este perfil é privado", + "image_process_failure": "Falha ao processar a imagem", + "required_field": "Este campo é obrigatório", + "displayname_min_length": "Nome de exibição deve ter pelo menos 3 caracteres", + "displayname_max_length": "Nome de exibição deve ter no máximo 50 caracteres" } } diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index 3384bdf7..cd4fc44c 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -277,6 +277,7 @@ "pending": "Pendentes", "no_pending_invites": "Não tens convites de amizade pendentes", "no_blocked_users": "Não tens nenhum utilizador bloqueado", - "friend_code_copied": "Código de amigo copiado" + "friend_code_copied": "Código de amigo copiado", + "image_process_failure": "Falha ao processar a imagem" } } diff --git a/src/main/constants.ts b/src/main/constants.ts index 850c9ada..c8cf182f 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -3,11 +3,8 @@ import path from "node:path"; export const defaultDownloadsPath = app.getPath("downloads"); -export const databasePath = path.join( - app.getPath("appData"), - "hydra", - "hydra.db" -); +export const databaseDirectory = path.join(app.getPath("appData"), "hydra"); +export const databasePath = path.join(databaseDirectory, "hydra.db"); export const logsPath = path.join(app.getPath("appData"), "hydra", "logs"); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index d44510ac..66adab39 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -54,6 +54,7 @@ import "./profile/get-me"; import "./profile/undo-friendship"; import "./profile/update-friend-request"; import "./profile/update-profile"; +import "./profile/process-profile-image"; import "./profile/send-friend-request"; import { isPortableVersion } from "@main/helpers"; diff --git a/src/main/events/profile/process-profile-image.ts b/src/main/events/profile/process-profile-image.ts new file mode 100644 index 00000000..f6d68088 --- /dev/null +++ b/src/main/events/profile/process-profile-image.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { PythonInstance } from "@main/services"; + +const processProfileImage = async ( + _event: Electron.IpcMainInvokeEvent, + path: string +) => { + return PythonInstance.processProfileImage(path); +}; + +registerEvent("processProfileImage", processProfileImage); diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index f748aea3..eb80bc47 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -1,50 +1,54 @@ import { registerEvent } from "../register-event"; -import { HydraApi, logger } from "@main/services"; -import axios from "axios"; +import { HydraApi, PythonInstance } from "@main/services"; import fs from "node:fs"; import path from "node:path"; -import { fileTypeFromFile } from "file-type"; import type { UpdateProfileRequest, UserProfile } from "@types"; +import { omit } from "lodash-es"; +import axios from "axios"; + +interface PresignedResponse { + presignedUrl: string; + profileImageUrl: string; +} const patchUserProfile = async (updateProfile: UpdateProfileRequest) => { - return HydraApi.patch("/profile", updateProfile); + return HydraApi.patch("/profile", updateProfile); +}; + +const getNewProfileImageUrl = async (localImageUrl: string) => { + const { imagePath, mimeType } = + await PythonInstance.processProfileImage(localImageUrl); + + const stats = fs.statSync(imagePath); + const fileBuffer = fs.readFileSync(imagePath); + const fileSizeInBytes = stats.size; + + const { presignedUrl, profileImageUrl } = + await HydraApi.post(`/presigned-urls/profile-image`, { + imageExt: path.extname(imagePath).slice(1), + imageLength: fileSizeInBytes, + }); + + await axios.put(presignedUrl, fileBuffer, { + headers: { + "Content-Type": mimeType, + }, + }); + + return profileImageUrl; }; const updateProfile = async ( _event: Electron.IpcMainInvokeEvent, updateProfile: UpdateProfileRequest -): Promise => { +) => { if (!updateProfile.profileImageUrl) { - return patchUserProfile(updateProfile); + return patchUserProfile(omit(updateProfile, "profileImageUrl")); } - const newProfileImagePath = updateProfile.profileImageUrl; - - const stats = fs.statSync(newProfileImagePath); - const fileBuffer = fs.readFileSync(newProfileImagePath); - const fileSizeInBytes = stats.size; - - const profileImageUrl = await HydraApi.post(`/presigned-urls/profile-image`, { - imageExt: path.extname(newProfileImagePath).slice(1), - imageLength: fileSizeInBytes, - }) - .then(async (preSignedResponse) => { - const { presignedUrl, profileImageUrl } = preSignedResponse; - - const mimeType = await fileTypeFromFile(newProfileImagePath); - - await axios.put(presignedUrl, fileBuffer, { - headers: { - "Content-Type": mimeType?.mime, - }, - }); - return profileImageUrl as string; - }) - .catch((err) => { - logger.error("Error uploading profile image", err); - - return undefined; - }); + const profileImageUrl = await getNewProfileImageUrl( + updateProfile.profileImageUrl + ).catch(() => undefined); return patchUserProfile({ ...updateProfile, profileImageUrl }); }; diff --git a/src/main/index.ts b/src/main/index.ts index 3c5cc254..00311b46 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,12 +4,14 @@ import updater from "electron-updater"; import i18n from "i18next"; import path from "node:path"; import url from "node:url"; +import fs from "node:fs"; import { electronApp, optimizer } from "@electron-toolkit/utils"; import { logger, PythonInstance, WindowManager } from "@main/services"; import { dataSource } from "@main/data-source"; import resources from "@locales"; import { userPreferencesRepository } from "@main/repository"; import { knexClient, migrationConfig } from "./knex-client"; +import { databaseDirectory } from "./constants"; const { autoUpdater } = updater; @@ -54,6 +56,10 @@ if (process.defaultApp) { } const runMigrations = async () => { + if (!fs.existsSync(databaseDirectory)) { + fs.mkdirSync(databaseDirectory, { recursive: true }); + } + await knexClient.migrate.list(migrationConfig).then((result) => { logger.log( "Migrations to run:", diff --git a/src/main/services/download/python-instance.ts b/src/main/services/download/python-instance.ts index 37ec17db..4a41c2dc 100644 --- a/src/main/services/download/python-instance.ts +++ b/src/main/services/download/python-instance.ts @@ -166,6 +166,14 @@ export class PythonInstance { this.downloadingGameId = -1; } + static async processProfileImage(imagePath: string) { + return this.rpc + .post<{ imagePath: string; mimeType: string }>("/profile-image", { + image_path: imagePath, + }) + .then((response) => response.data); + } + private static async handleRpcError(_error: unknown) { await this.rpc.get("/healthcheck").catch(() => { logger.error( diff --git a/src/preload/index.ts b/src/preload/index.ts index ac22e37d..5103e333 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -145,6 +145,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("undoFriendship", userId), updateProfile: (updateProfile: UpdateProfileRequest) => ipcRenderer.invoke("updateProfile", updateProfile), + processProfileImage: (imagePath: string) => + ipcRenderer.invoke("processProfileImage", imagePath), getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), updateFriendRequest: (userId: string, action: FriendRequestAction) => ipcRenderer.invoke("updateFriendRequest", userId, action), diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 3d34b647..aa5b1d9f 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -150,6 +150,10 @@ declare global { updateProfile: ( updateProfile: UpdateProfileRequest ) => Promise; + updateProfile: (updateProfile: UpdateProfileProps) => Promise; + processProfileImage: ( + path: string + ) => Promise<{ imagePath: string; mimeType: string }>; getFriendRequests: () => Promise; updateFriendRequest: ( userId: string, diff --git a/src/renderer/src/pages/profile/edit-profile-modal/edit-profile-modal.tsx b/src/renderer/src/pages/profile/edit-profile-modal/edit-profile-modal.tsx index 9e73fab4..a9f06e20 100644 --- a/src/renderer/src/pages/profile/edit-profile-modal/edit-profile-modal.tsx +++ b/src/renderer/src/pages/profile/edit-profile-modal/edit-profile-modal.tsx @@ -102,7 +102,7 @@ export function EditProfileModal( filters: [ { name: "Image", - extensions: ["jpg", "jpeg", "png"], + extensions: ["jpg", "jpeg", "png", "gif", "webp"], }, ], }); @@ -110,7 +110,14 @@ export function EditProfileModal( if (filePaths && filePaths.length > 0) { const path = filePaths[0]; - onChange(path); + const { imagePath } = await window.electron + .processProfileImage(path) + .catch(() => { + showErrorToast(t("image_process_failure")); + return { imagePath: null }; + }); + + onChange(imagePath); } }; diff --git a/torrent-client/main.py b/torrent-client/main.py index a2ea190b..7fbc49d8 100644 --- a/torrent-client/main.py +++ b/torrent-client/main.py @@ -4,6 +4,7 @@ import json import urllib.parse import psutil from torrent_downloader import TorrentDownloader +from profile_image_processor import ProfileImageProcessor torrent_port = sys.argv[1] http_port = sys.argv[2] @@ -73,16 +74,30 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): global torrent_downloader - if self.path == "/action": - if self.headers.get(self.rpc_password_header) != rpc_password: - self.send_response(401) + if self.headers.get(self.rpc_password_header) != rpc_password: + self.send_response(401) + self.end_headers() + return + + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + data = json.loads(post_data.decode('utf-8')) + + if self.path == "/profile-image": + parsed_image_path = data['image_path'] + + try: + parsed_image_path, mime_type = ProfileImageProcessor.process_image(parsed_image_path) + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + self.wfile.write(json.dumps({'imagePath': parsed_image_path, 'mimeType': mime_type}).encode('utf-8')) + except: + self.send_response(400) self.end_headers() - return - - content_length = int(self.headers['Content-Length']) - post_data = self.rfile.read(content_length) - data = json.loads(post_data.decode('utf-8')) + elif self.path == "/action": if torrent_downloader is None: torrent_downloader = TorrentDownloader(torrent_port) @@ -99,6 +114,10 @@ class Handler(BaseHTTPRequestHandler): self.send_response(200) self.end_headers() + else: + self.send_response(404) + self.end_headers() + if __name__ == "__main__": httpd = HTTPServer(("", int(http_port)), Handler) diff --git a/torrent-client/profile_image_processor.py b/torrent-client/profile_image_processor.py new file mode 100644 index 00000000..3a565aef --- /dev/null +++ b/torrent-client/profile_image_processor.py @@ -0,0 +1,31 @@ +from PIL import Image +import tempfile +import os, uuid + +class ProfileImageProcessor: + + @staticmethod + def get_parsed_image_data(image_path): + Image.MAX_IMAGE_PIXELS = 933120000 + + image = Image.open(image_path) + + try: + image.seek(1) + except EOFError: + mime_type = image.get_format_mimetype() + return image_path, mime_type + else: + newUUID = str(uuid.uuid4()) + new_image_path = os.path.join(tempfile.gettempdir(), newUUID) + ".webp" + image.save(new_image_path) + + new_image = Image.open(new_image_path) + mime_type = new_image.get_format_mimetype() + + return new_image_path, mime_type + + + @staticmethod + def process_image(image_path): + return ProfileImageProcessor.get_parsed_image_data(image_path) diff --git a/yarn.lock b/yarn.lock index 3bf6f2cb..9aa73bd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4347,15 +4347,6 @@ file-type@^18.7.0: strtok3 "^7.0.0" token-types "^5.0.1" -file-type@^19.0.0: - version "19.0.0" - resolved "https://registry.npmjs.org/file-type/-/file-type-19.0.0.tgz" - integrity sha512-s7cxa7/leUWLiXO78DVVfBVse+milos9FitauDLG1pI7lNaJ2+5lzPnr2N24ym+84HVwJL6hVuGfgVE+ALvU8Q== - dependencies: - readable-web-to-node-stream "^3.0.2" - strtok3 "^7.0.0" - token-types "^5.0.1" - file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz"