[RELEASE] v3.3.2-beta (#372)

This commit is contained in:
Daniel Luiz Alves 2025-12-10 02:37:20 -03:00 committed by GitHub
commit 5cdc8c41e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1003 additions and 363 deletions

View File

@ -163,11 +163,11 @@ mkdir -p /app/server/uploads /app/server/temp-uploads /app/server/prisma /app/se
# This runs on EVERY startup to handle updates and corrupted metadata
echo "🔐 Fixing permissions for internal storage..."
# DYNAMIC: Detect palmr user's actual UID and GID
# Works with any Docker --user configuration
PALMR_UID=\$(id -u palmr 2>/dev/null || echo "1001")
PALMR_GID=\$(id -g palmr 2>/dev/null || echo "1001")
echo " Target user: palmr (UID:\$PALMR_UID, GID:\$PALMR_GID)"
# USE ENVIRONMENT VARIABLES: Allow runtime UID/GID configuration
# Falls back to palmr user's UID/GID if not specified
TARGET_UID=\${PALMR_UID:-\$(id -u palmr 2>/dev/null || echo "1001")}
TARGET_GID=\${PALMR_GID:-\$(id -g palmr 2>/dev/null || echo "1001")}
echo " Target user: palmr (UID:\$TARGET_UID, GID:\$TARGET_GID)"
# ALWAYS remove storage system metadata to prevent corruption issues
# This is safe - storage system recreates it automatically
@ -177,10 +177,59 @@ if [ -d "/app/server/minio-data/.minio.sys" ]; then
rm -rf /app/server/minio-data/.minio.sys 2>/dev/null || true
fi
# Fix ownership and permissions (safe for updates)
echo " 🔧 Setting ownership and permissions..."
chown -R \$PALMR_UID:\$PALMR_GID /app/server 2>/dev/null || echo " ⚠️ chown skipped"
chmod -R 755 /app/server 2>/dev/null || echo " ⚠️ chmod skipped"
# SMART CHOWN: Only run expensive recursive chown when UID/GID changed
# This dramatically speeds up subsequent starts
UIDGID_MARKER="/app/server/.palmr-uidgid"
CURRENT_OWNER="\$TARGET_UID:\$TARGET_GID"
NEEDS_CHOWN=false
if [ -f "\$UIDGID_MARKER" ]; then
STORED_OWNER=\$(cat "\$UIDGID_MARKER" 2>/dev/null || echo "")
if [ "\$STORED_OWNER" != "\$CURRENT_OWNER" ]; then
echo " 📝 UID/GID changed (\$STORED_OWNER → \$CURRENT_OWNER)"
NEEDS_CHOWN=true
else
echo " ✓ UID/GID unchanged (\$CURRENT_OWNER), skipping chown"
fi
else
echo " 📝 First run or marker missing, will set ownership"
NEEDS_CHOWN=true
fi
if [ "\$NEEDS_CHOWN" = "true" ]; then
echo " 🔧 Setting ownership (this may take a moment on first run)..."
# Only chown the directories that need it
chown \$TARGET_UID:\$TARGET_GID /app/server 2>/dev/null || true
# For most directories, just chown the directory itself (fast)
for dir in uploads temp-uploads; do
if [ -d "/app/server/\$dir" ]; then
chown \$TARGET_UID:\$TARGET_GID "/app/server/\$dir" 2>/dev/null || true
fi
done
# For prisma directory, we need recursive chown for database files
if [ -d "/app/server/prisma" ]; then
echo " 🔧 Fixing database permissions..."
chown -R \$TARGET_UID:\$TARGET_GID "/app/server/prisma" 2>/dev/null || true
fi
# For minio-data, we NEED recursive chown because MinIO creates subdirectories
# and needs write access to all of them
if [ -d "/app/server/minio-data" ]; then
echo " 🔧 Fixing MinIO storage permissions..."
chown -R \$TARGET_UID:\$TARGET_GID "/app/server/minio-data" 2>/dev/null || true
fi
# Save current UID/GID to marker
echo "\$CURRENT_OWNER" > "\$UIDGID_MARKER"
chown \$TARGET_UID:\$TARGET_GID "\$UIDGID_MARKER" 2>/dev/null || true
echo " ✅ Ownership updated and cached"
fi
chmod 755 /app/server 2>/dev/null || echo " ⚠️ chmod skipped"
# Verify critical directories are writable
if touch /app/server/.test-write 2>/dev/null; then

View File

@ -1,6 +1,6 @@
{
"name": "palmr-docs",
"version": "3.3.1-beta",
"version": "3.3.2-beta",
"description": "Docs for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@ -36,7 +36,7 @@
"fumadocs-ui": "15.2.7",
"lucide-react": "^0.525.0",
"motion": "^12.23.0",
"next": "15.3.4",
"next": "15.3.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.2.0"
@ -53,7 +53,7 @@
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"eslint": "9.30.0",
"eslint-config-next": "15.3.4",
"eslint-config-next": "15.3.6",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-prettier": "5.5.1",
"postcss": "^8.5.6",

122
apps/docs/pnpm-lock.yaml generated
View File

@ -19,13 +19,13 @@ importers:
version: 2.1.1
fumadocs-core:
specifier: 15.2.7
version: 15.2.7(@types/react@19.1.8)(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 15.2.7(@types/react@19.1.8)(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
fumadocs-mdx:
specifier: 11.6.10
version: 11.6.10(acorn@8.15.0)(fumadocs-core@15.2.7(@types/react@19.1.8)(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
version: 11.6.10(acorn@8.15.0)(fumadocs-core@15.2.7(@types/react@19.1.8)(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
fumadocs-ui:
specifier: 15.2.7
version: 15.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.1.11)
version: 15.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.1.11)
lucide-react:
specifier: ^0.525.0
version: 0.525.0(react@19.1.0)
@ -33,8 +33,8 @@ importers:
specifier: ^12.23.0
version: 12.23.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next:
specifier: 15.3.4
version: 15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
specifier: 15.3.6
version: 15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react:
specifier: ^19.1.0
version: 19.1.0
@ -79,8 +79,8 @@ importers:
specifier: 9.30.0
version: 9.30.0(jiti@2.4.2)
eslint-config-next:
specifier: 15.3.4
version: 15.3.4(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3)
specifier: 15.3.6
version: 15.3.6(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3)
eslint-config-prettier:
specifier: 9.1.0
version: 9.1.0(eslint@9.30.0(jiti@2.4.2))
@ -540,56 +540,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.11':
resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==}
'@next/env@15.3.4':
resolution: {integrity: sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ==}
'@next/env@15.3.6':
resolution: {integrity: sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw==}
'@next/eslint-plugin-next@15.3.4':
resolution: {integrity: sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg==}
'@next/eslint-plugin-next@15.3.6':
resolution: {integrity: sha512-gvt7l1r4N0zHCXyXYj39ObrTBr8TxyA/306Z/kjseYk6hiefu3zexRKRVjVmQqUpxe9oxyfYWMZFtsBYPgr1oA==}
'@next/swc-darwin-arm64@15.3.4':
resolution: {integrity: sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg==}
'@next/swc-darwin-arm64@15.3.5':
resolution: {integrity: sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.3.4':
resolution: {integrity: sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw==}
'@next/swc-darwin-x64@15.3.5':
resolution: {integrity: sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.3.4':
resolution: {integrity: sha512-l8ZQOCCg7adwmsnFm8m5q9eIPAHdaB2F3cxhufYtVo84pymwKuWfpYTKcUiFcutJdp9xGHC+F1Uq3xnFU1B/7g==}
'@next/swc-linux-arm64-gnu@15.3.5':
resolution: {integrity: sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.3.4':
resolution: {integrity: sha512-wFyZ7X470YJQtpKot4xCY3gpdn8lE9nTlldG07/kJYexCUpX1piX+MBfZdvulo+t1yADFVEuzFfVHfklfEx8kw==}
'@next/swc-linux-arm64-musl@15.3.5':
resolution: {integrity: sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@15.3.4':
resolution: {integrity: sha512-gEbH9rv9o7I12qPyvZNVTyP/PWKqOp8clvnoYZQiX800KkqsaJZuOXkWgMa7ANCCh/oEN2ZQheh3yH8/kWPSEg==}
'@next/swc-linux-x64-gnu@15.3.5':
resolution: {integrity: sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.3.4':
resolution: {integrity: sha512-Cf8sr0ufuC/nu/yQ76AnarbSAXcwG/wj+1xFPNbyNo8ltA6kw5d5YqO8kQuwVIxk13SBdtgXrNyom3ZosHAy4A==}
'@next/swc-linux-x64-musl@15.3.5':
resolution: {integrity: sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@15.3.4':
resolution: {integrity: sha512-ay5+qADDN3rwRbRpEhTOreOn1OyJIXS60tg9WMYTWCy3fB6rGoyjLVxc4dR9PYjEdR2iDYsaF5h03NA+XuYPQQ==}
'@next/swc-win32-arm64-msvc@15.3.5':
resolution: {integrity: sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.3.4':
resolution: {integrity: sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg==}
'@next/swc-win32-x64-msvc@15.3.5':
resolution: {integrity: sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -1669,8 +1669,8 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-next@15.3.4:
resolution: {integrity: sha512-WqeumCq57QcTP2lYlV6BRUySfGiBYEXlQ1L0mQ+u4N4X4ZhUVSSQ52WtjqHv60pJ6dD7jn+YZc0d1/ZSsxccvg==}
eslint-config-next@15.3.6:
resolution: {integrity: sha512-UylZINx8zjSgKHFn60h6Pjwgb40xkJ1ip9jfJ5t7D9/TJNnBIMoH5MtDWdEMatby3jiUB3twvk5cZgtOGOh9Qg==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
@ -2593,8 +2593,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.3.4:
resolution: {integrity: sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==}
next@15.3.6:
resolution: {integrity: sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@ -3579,34 +3579,34 @@ snapshots:
'@tybys/wasm-util': 0.9.0
optional: true
'@next/env@15.3.4': {}
'@next/env@15.3.6': {}
'@next/eslint-plugin-next@15.3.4':
'@next/eslint-plugin-next@15.3.6':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.3.4':
'@next/swc-darwin-arm64@15.3.5':
optional: true
'@next/swc-darwin-x64@15.3.4':
'@next/swc-darwin-x64@15.3.5':
optional: true
'@next/swc-linux-arm64-gnu@15.3.4':
'@next/swc-linux-arm64-gnu@15.3.5':
optional: true
'@next/swc-linux-arm64-musl@15.3.4':
'@next/swc-linux-arm64-musl@15.3.5':
optional: true
'@next/swc-linux-x64-gnu@15.3.4':
'@next/swc-linux-x64-gnu@15.3.5':
optional: true
'@next/swc-linux-x64-musl@15.3.4':
'@next/swc-linux-x64-musl@15.3.5':
optional: true
'@next/swc-win32-arm64-msvc@15.3.4':
'@next/swc-win32-arm64-msvc@15.3.5':
optional: true
'@next/swc-win32-x64-msvc@15.3.4':
'@next/swc-win32-x64-msvc@15.3.5':
optional: true
'@nodelib/fs.scandir@2.1.5':
@ -4771,9 +4771,9 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-config-next@15.3.4(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3):
eslint-config-next@15.3.6(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3):
dependencies:
'@next/eslint-plugin-next': 15.3.4
'@next/eslint-plugin-next': 15.3.6
'@rushstack/eslint-patch': 1.12.0
'@typescript-eslint/eslint-plugin': 8.35.1(@typescript-eslint/parser@8.35.1(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/parser': 8.35.1(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3)
@ -5085,7 +5085,7 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
fumadocs-core@15.2.7(@types/react@19.1.8)(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
fumadocs-core@15.2.7(@types/react@19.1.8)(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@formatjs/intl-localematcher': 0.6.1
'@orama/orama': 3.1.10
@ -5103,21 +5103,21 @@ snapshots:
shiki: 3.7.0
unist-util-visit: 5.0.0
optionalDependencies:
next: 15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
transitivePeerDependencies:
- '@types/react'
- supports-color
fumadocs-mdx@11.6.10(acorn@8.15.0)(fumadocs-core@15.2.7(@types/react@19.1.8)(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)):
fumadocs-mdx@11.6.10(acorn@8.15.0)(fumadocs-core@15.2.7(@types/react@19.1.8)(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)):
dependencies:
'@mdx-js/mdx': 3.1.0(acorn@8.15.0)
'@standard-schema/spec': 1.0.0
chokidar: 4.0.3
esbuild: 0.25.5
estree-util-value-to-estree: 3.4.0
fumadocs-core: 15.2.7(@types/react@19.1.8)(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
fumadocs-core: 15.2.7(@types/react@19.1.8)(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
js-yaml: 4.1.0
lru-cache: 11.1.0
picocolors: 1.1.1
@ -5126,12 +5126,12 @@ snapshots:
unist-util-visit: 5.0.0
zod: 3.25.74
optionalDependencies:
next: 15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
transitivePeerDependencies:
- acorn
- supports-color
fumadocs-ui@15.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.1.11):
fumadocs-ui@15.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.1.11):
dependencies:
'@radix-ui/react-accordion': 1.2.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-collapsible': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -5143,10 +5143,10 @@ snapshots:
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-tabs': 1.1.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
class-variance-authority: 0.7.1
fumadocs-core: 15.2.7(@types/react@19.1.8)(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
fumadocs-core: 15.2.7(@types/react@19.1.8)(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
lodash.merge: 4.6.2
lucide-react: 0.487.0(react@19.1.0)
next: 15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-themes: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
postcss-selector-parser: 7.1.0
react: 19.1.0
@ -6076,9 +6076,9 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 15.3.4
'@next/env': 15.3.6
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@ -6088,14 +6088,14 @@ snapshots:
react-dom: 19.1.0(react@19.1.0)
styled-jsx: 5.1.6(react@19.1.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.3.4
'@next/swc-darwin-x64': 15.3.4
'@next/swc-linux-arm64-gnu': 15.3.4
'@next/swc-linux-arm64-musl': 15.3.4
'@next/swc-linux-x64-gnu': 15.3.4
'@next/swc-linux-x64-musl': 15.3.4
'@next/swc-win32-arm64-msvc': 15.3.4
'@next/swc-win32-x64-msvc': 15.3.4
'@next/swc-darwin-arm64': 15.3.5
'@next/swc-darwin-x64': 15.3.5
'@next/swc-linux-arm64-gnu': 15.3.5
'@next/swc-linux-arm64-musl': 15.3.5
'@next/swc-linux-x64-gnu': 15.3.5
'@next/swc-linux-x64-musl': 15.3.5
'@next/swc-win32-arm64-msvc': 15.3.5
'@next/swc-win32-x64-msvc': 15.3.5
sharp: 0.34.2
transitivePeerDependencies:
- '@babel/core'

View File

@ -1,6 +1,6 @@
{
"name": "palmr-api",
"version": "3.3.1-beta",
"version": "3.3.2-beta",
"description": "API for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@ -88,6 +88,9 @@ export const s3Client = hasValidConfig
secretAccessKey: storageConfig.secretKey,
},
forcePathStyle: storageConfig.forcePathStyle,
requestHandler: {
requestTimeout: 300000, // 5 minutes timeout for S3 operations
},
})
: null;
@ -139,5 +142,8 @@ export function createPublicS3Client(): S3Client | null {
secretAccessKey: storageConfig.secretKey,
},
forcePathStyle: storageConfig.forcePathStyle,
requestHandler: {
requestTimeout: 300000, // 5 minutes timeout for S3 operations
},
});
}

View File

@ -313,6 +313,7 @@ export class FileController {
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
reply.header("Content-Length", reverseShareFile.size.toString());
return reply.send(stream);
}
@ -369,6 +370,7 @@ export class FileController {
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
reply.header("Content-Length", fileRecord.size.toString());
return reply.send(stream);
} catch (error) {
@ -612,6 +614,7 @@ export class FileController {
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
reply.header("Content-Length", fileRecord.size.toString());
reply.header("Cache-Control", "public, max-age=31536000"); // Cache por 1 ano
return reply.send(stream);

View File

@ -486,6 +486,159 @@ export class ReverseShareController {
}
}
// Multipart upload endpoints for reverse shares
async createMultipartUploadByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };
const { password } = request.query as { password?: string };
const { filename, extension } = request.body as { filename: string; extension: string };
if (!filename || !extension) {
return reply.status(400).send({ error: "filename and extension are required" });
}
const result = await this.reverseShareService.createMultipartUploadByAlias(alias, filename, extension, password);
return reply.status(200).send({
uploadId: result.uploadId,
objectName: result.objectName,
message: "Multipart upload initialized",
});
} catch (error: any) {
console.error("[Multipart] Create multipart upload error:", error);
if (error.message === "Reverse share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Reverse share is inactive") {
return reply.status(403).send({ error: error.message });
}
if (error.message === "Reverse share has expired") {
return reply.status(410).send({ error: error.message });
}
if (error.message === "Password required" || error.message === "Invalid password") {
return reply.status(401).send({ error: error.message });
}
return reply.status(500).send({ error: "Failed to create multipart upload" });
}
}
async getMultipartPartUrlByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };
const { password, uploadId, objectName, partNumber } = request.query as {
password?: string;
uploadId: string;
objectName: string;
partNumber: string;
};
if (!uploadId || !objectName || !partNumber) {
return reply.status(400).send({ error: "uploadId, objectName, and partNumber are required" });
}
const partNum = parseInt(partNumber);
if (isNaN(partNum) || partNum < 1 || partNum > 10000) {
return reply.status(400).send({ error: "partNumber must be between 1 and 10000" });
}
const result = await this.reverseShareService.getMultipartPartUrlByAlias(
alias,
uploadId,
objectName,
partNum,
password
);
return reply.status(200).send({ url: result.url });
} catch (error: any) {
console.error("[Multipart] Get part URL error:", error);
if (error.message === "Reverse share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Reverse share is inactive") {
return reply.status(403).send({ error: error.message });
}
if (error.message === "Reverse share has expired") {
return reply.status(410).send({ error: error.message });
}
if (error.message === "Password required" || error.message === "Invalid password") {
return reply.status(401).send({ error: error.message });
}
return reply.status(500).send({ error: "Failed to get presigned URL for part" });
}
}
async completeMultipartUploadByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };
const { password } = request.query as { password?: string };
const { uploadId, objectName, parts } = request.body as {
uploadId: string;
objectName: string;
parts: Array<{ PartNumber: number; ETag: string }>;
};
if (!uploadId || !objectName || !parts || !Array.isArray(parts)) {
return reply.status(400).send({ error: "uploadId, objectName, and parts are required" });
}
const result = await this.reverseShareService.completeMultipartUploadByAlias(
alias,
uploadId,
objectName,
parts,
password
);
return reply.status(200).send(result);
} catch (error: any) {
console.error("[Multipart] Complete multipart upload error:", error);
if (error.message === "Reverse share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Reverse share is inactive") {
return reply.status(403).send({ error: error.message });
}
if (error.message === "Reverse share has expired") {
return reply.status(410).send({ error: error.message });
}
if (error.message === "Password required" || error.message === "Invalid password") {
return reply.status(401).send({ error: error.message });
}
return reply.status(500).send({ error: "Failed to complete multipart upload" });
}
}
async abortMultipartUploadByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };
const { password } = request.query as { password?: string };
const { uploadId, objectName } = request.body as {
uploadId: string;
objectName: string;
};
if (!uploadId || !objectName) {
return reply.status(400).send({ error: "uploadId and objectName are required" });
}
const result = await this.reverseShareService.abortMultipartUploadByAlias(alias, uploadId, objectName, password);
return reply.status(200).send(result);
} catch (error: any) {
console.error("[Multipart] Abort multipart upload error:", error);
if (error.message === "Reverse share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Reverse share is inactive") {
return reply.status(403).send({ error: error.message });
}
if (error.message === "Reverse share has expired") {
return reply.status(410).send({ error: error.message });
}
if (error.message === "Password required" || error.message === "Invalid password") {
return reply.status(401).send({ error: error.message });
}
return reply.status(500).send({ error: "Failed to abort multipart upload" });
}
}
async getReverseShareMetadataByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };

View File

@ -593,6 +593,154 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.copyFileToUserFiles.bind(reverseShareController)
);
// Multipart upload routes for reverse shares (public - no auth required)
app.post(
"/reverse-shares/alias/:alias/multipart/create",
{
schema: {
tags: ["Reverse Share"],
operationId: "createMultipartUploadByAlias",
summary: "Create Multipart Upload for Reverse Share (Public)",
description:
"Initializes a multipart upload for large files (≥100MB) to a reverse share. Returns uploadId for subsequent part uploads.",
params: z.object({
alias: z.string().describe("Alias of the reverse share"),
}),
querystring: z.object({
password: z.string().optional().describe("Password for accessing password-protected reverse shares"),
}),
body: z.object({
filename: z.string().min(1).describe("The filename without extension"),
extension: z.string().min(1).describe("The file extension"),
}),
response: {
200: z.object({
uploadId: z.string().describe("The upload ID for this multipart upload"),
objectName: z.string().describe("The object name in storage"),
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
403: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
410: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
reverseShareController.createMultipartUploadByAlias.bind(reverseShareController)
);
app.get(
"/reverse-shares/alias/:alias/multipart/part-url",
{
schema: {
tags: ["Reverse Share"],
operationId: "getMultipartPartUrlByAlias",
summary: "Get Presigned URL for Part (Public)",
description: "Gets a presigned URL for uploading a specific part of a multipart upload to a reverse share",
params: z.object({
alias: z.string().describe("Alias of the reverse share"),
}),
querystring: z.object({
password: z.string().optional().describe("Password for accessing password-protected reverse shares"),
uploadId: z.string().min(1).describe("The multipart upload ID"),
objectName: z.string().min(1).describe("The object name"),
partNumber: z.string().min(1).describe("The part number (1-10000)"),
}),
response: {
200: z.object({
url: z.string().describe("The presigned URL for uploading this part"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
403: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
410: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
reverseShareController.getMultipartPartUrlByAlias.bind(reverseShareController)
);
app.post(
"/reverse-shares/alias/:alias/multipart/complete",
{
schema: {
tags: ["Reverse Share"],
operationId: "completeMultipartUploadByAlias",
summary: "Complete Multipart Upload (Public)",
description: "Completes a multipart upload to a reverse share by combining all uploaded parts",
params: z.object({
alias: z.string().describe("Alias of the reverse share"),
}),
querystring: z.object({
password: z.string().optional().describe("Password for accessing password-protected reverse shares"),
}),
body: z.object({
uploadId: z.string().min(1).describe("The multipart upload ID"),
objectName: z.string().min(1).describe("The object name"),
parts: z
.array(
z.object({
PartNumber: z.number().min(1).max(10000).describe("The part number"),
ETag: z.string().min(1).describe("The ETag returned from uploading the part"),
})
)
.describe("Array of uploaded parts"),
}),
response: {
200: z.object({
message: z.string().describe("Success message"),
objectName: z.string().describe("The completed object name"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
403: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
410: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
reverseShareController.completeMultipartUploadByAlias.bind(reverseShareController)
);
app.post(
"/reverse-shares/alias/:alias/multipart/abort",
{
schema: {
tags: ["Reverse Share"],
operationId: "abortMultipartUploadByAlias",
summary: "Abort Multipart Upload (Public)",
description: "Aborts a multipart upload to a reverse share and cleans up all uploaded parts",
params: z.object({
alias: z.string().describe("Alias of the reverse share"),
}),
querystring: z.object({
password: z.string().optional().describe("Password for accessing password-protected reverse shares"),
}),
body: z.object({
uploadId: z.string().min(1).describe("The multipart upload ID"),
objectName: z.string().min(1).describe("The object name"),
}),
response: {
200: z.object({
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
403: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
410: z.object({ error: z.string() }),
500: z.object({ error: z.string() }),
},
},
},
reverseShareController.abortMultipartUploadByAlias.bind(reverseShareController)
);
app.get(
"/reverse-shares/alias/:alias/metadata",
{

View File

@ -780,6 +780,101 @@ export class ReverseShareService {
return result;
}
// Helper method to validate reverse share access (reduces duplication)
private async validateReverseShareAccessByAlias(alias: string, password?: string) {
const reverseShare = await this.reverseShareRepository.findByAlias(alias);
if (!reverseShare) {
throw new Error("Reverse share not found");
}
if (!reverseShare.isActive) {
throw new Error("Reverse share is inactive");
}
if (reverseShare.expiration && new Date(reverseShare.expiration) < new Date()) {
throw new Error("Reverse share has expired");
}
if (reverseShare.password) {
if (!password) {
throw new Error("Password required");
}
const isValidPassword = await this.reverseShareRepository.comparePassword(password, reverseShare.password);
if (!isValidPassword) {
throw new Error("Invalid password");
}
}
return reverseShare;
}
// Multipart upload methods for reverse shares
async createMultipartUploadByAlias(
alias: string,
filename: string,
extension: string,
password?: string
): Promise<{ uploadId: string; objectName: string }> {
await this.validateReverseShareAccessByAlias(alias, password);
// Generate unique object name using timestamp and random suffix
const objectName = `reverse-shares/${alias}/${Date.now()}-${Math.random().toString(36).substring(7)}-${filename}.${extension}`;
const uploadId = await this.fileService.createMultipartUpload(objectName);
return {
uploadId,
objectName,
};
}
async getMultipartPartUrlByAlias(
alias: string,
uploadId: string,
objectName: string,
partNumber: number,
password?: string
): Promise<{ url: string }> {
await this.validateReverseShareAccessByAlias(alias, password);
const expires = parseInt(env.PRESIGNED_URL_EXPIRATION);
const url = await this.fileService.getPresignedPartUrl(objectName, uploadId, partNumber, expires);
return { url };
}
async completeMultipartUploadByAlias(
alias: string,
uploadId: string,
objectName: string,
parts: Array<{ PartNumber: number; ETag: string }>,
password?: string
): Promise<{ message: string; objectName: string }> {
await this.validateReverseShareAccessByAlias(alias, password);
await this.fileService.completeMultipartUpload(objectName, uploadId, parts);
return {
message: "Multipart upload completed successfully",
objectName,
};
}
async abortMultipartUploadByAlias(
alias: string,
uploadId: string,
objectName: string,
password?: string
): Promise<{ message: string }> {
await this.validateReverseShareAccessByAlias(alias, password);
await this.fileService.abortMultipartUpload(objectName, uploadId);
return {
message: "Multipart upload aborted successfully",
};
}
private formatFileResponse(file: any) {
return {
id: file.id,

View File

@ -90,7 +90,10 @@ export class S3StorageProvider implements StorageProvider {
Key: objectName,
});
return await getSignedUrl(client, command, { expiresIn: expires });
return await getSignedUrl(client, command, {
expiresIn: expires,
unsignableHeaders: new Set(["x-amz-checksum-crc32"]),
});
}
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
@ -179,10 +182,7 @@ export class S3StorageProvider implements StorageProvider {
* Returns uploadId for subsequent part uploads
*/
async createMultipartUpload(objectName: string): Promise<string> {
const client = createPublicS3Client();
if (!client) {
throw new Error("S3 client could not be created");
}
const client = this.ensureClient();
const command = new CreateMultipartUploadCommand({
Bucket: bucketName,
@ -219,7 +219,10 @@ export class S3StorageProvider implements StorageProvider {
PartNumber: partNumber,
});
const url = await getSignedUrl(client, command, { expiresIn: expires });
const url = await getSignedUrl(client, command, {
expiresIn: expires,
unsignableHeaders: new Set(["x-amz-checksum-crc32"]),
});
return url;
}

View File

@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "3.3.1-beta",
"version": "3.3.2-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",
@ -63,7 +63,7 @@
"jszip": "^3.10.1",
"lucide-react": "^0.525.0",
"nanoid": "^5.1.5",
"next": "15.3.4",
"next": "15.3.6",
"next-intl": "^4.3.1",
"next-themes": "^0.4.6",
"nookies": "^2.5.2",
@ -96,7 +96,7 @@
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"eslint": "9.30.0",
"eslint-config-next": "15.3.4",
"eslint-config-next": "15.3.6",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-prettier": "5.5.1",
"prettier": "3.6.2",

106
apps/web/pnpm-lock.yaml generated
View File

@ -105,11 +105,11 @@ importers:
specifier: ^5.1.5
version: 5.1.5
next:
specifier: 15.3.4
version: 15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
specifier: 15.3.6
version: 15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-intl:
specifier: ^4.3.1
version: 4.3.4(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
version: 4.3.4(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -199,8 +199,8 @@ importers:
specifier: 9.30.0
version: 9.30.0(jiti@2.4.2)
eslint-config-next:
specifier: 15.3.4
version: 15.3.4(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3)
specifier: 15.3.6
version: 15.3.6(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3)
eslint-config-prettier:
specifier: 9.1.0
version: 9.1.0(eslint@9.30.0(jiti@2.4.2))
@ -531,56 +531,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.11':
resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==}
'@next/env@15.3.4':
resolution: {integrity: sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ==}
'@next/env@15.3.6':
resolution: {integrity: sha512-/cK+QPcfRbDZxmI/uckT4lu9pHCfRIPBLqy88MhE+7Vg5hKrEYc333Ae76dn/cw2FBP2bR/GoK/4DU+U7by/Nw==}
'@next/eslint-plugin-next@15.3.4':
resolution: {integrity: sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg==}
'@next/eslint-plugin-next@15.3.6':
resolution: {integrity: sha512-gvt7l1r4N0zHCXyXYj39ObrTBr8TxyA/306Z/kjseYk6hiefu3zexRKRVjVmQqUpxe9oxyfYWMZFtsBYPgr1oA==}
'@next/swc-darwin-arm64@15.3.4':
resolution: {integrity: sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg==}
'@next/swc-darwin-arm64@15.3.5':
resolution: {integrity: sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.3.4':
resolution: {integrity: sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw==}
'@next/swc-darwin-x64@15.3.5':
resolution: {integrity: sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.3.4':
resolution: {integrity: sha512-l8ZQOCCg7adwmsnFm8m5q9eIPAHdaB2F3cxhufYtVo84pymwKuWfpYTKcUiFcutJdp9xGHC+F1Uq3xnFU1B/7g==}
'@next/swc-linux-arm64-gnu@15.3.5':
resolution: {integrity: sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.3.4':
resolution: {integrity: sha512-wFyZ7X470YJQtpKot4xCY3gpdn8lE9nTlldG07/kJYexCUpX1piX+MBfZdvulo+t1yADFVEuzFfVHfklfEx8kw==}
'@next/swc-linux-arm64-musl@15.3.5':
resolution: {integrity: sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@15.3.4':
resolution: {integrity: sha512-gEbH9rv9o7I12qPyvZNVTyP/PWKqOp8clvnoYZQiX800KkqsaJZuOXkWgMa7ANCCh/oEN2ZQheh3yH8/kWPSEg==}
'@next/swc-linux-x64-gnu@15.3.5':
resolution: {integrity: sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.3.4':
resolution: {integrity: sha512-Cf8sr0ufuC/nu/yQ76AnarbSAXcwG/wj+1xFPNbyNo8ltA6kw5d5YqO8kQuwVIxk13SBdtgXrNyom3ZosHAy4A==}
'@next/swc-linux-x64-musl@15.3.5':
resolution: {integrity: sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@15.3.4':
resolution: {integrity: sha512-ay5+qADDN3rwRbRpEhTOreOn1OyJIXS60tg9WMYTWCy3fB6rGoyjLVxc4dR9PYjEdR2iDYsaF5h03NA+XuYPQQ==}
'@next/swc-win32-arm64-msvc@15.3.5':
resolution: {integrity: sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.3.4':
resolution: {integrity: sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg==}
'@next/swc-win32-x64-msvc@15.3.5':
resolution: {integrity: sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -1818,8 +1818,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-next@15.3.4:
resolution: {integrity: sha512-WqeumCq57QcTP2lYlV6BRUySfGiBYEXlQ1L0mQ+u4N4X4ZhUVSSQ52WtjqHv60pJ6dD7jn+YZc0d1/ZSsxccvg==}
eslint-config-next@15.3.6:
resolution: {integrity: sha512-UylZINx8zjSgKHFn60h6Pjwgb40xkJ1ip9jfJ5t7D9/TJNnBIMoH5MtDWdEMatby3jiUB3twvk5cZgtOGOh9Qg==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
@ -2539,8 +2539,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.3.4:
resolution: {integrity: sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==}
next@15.3.6:
resolution: {integrity: sha512-oI6D1zbbsh6JzzZFDCSHnnx6Qpvd1fSkVJu/5d8uluqnxzuoqtodVZjYvNovooznUq8udSAiKp7MbwlfZ8Gm6w==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@ -3540,34 +3540,34 @@ snapshots:
'@tybys/wasm-util': 0.9.0
optional: true
'@next/env@15.3.4': {}
'@next/env@15.3.6': {}
'@next/eslint-plugin-next@15.3.4':
'@next/eslint-plugin-next@15.3.6':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.3.4':
'@next/swc-darwin-arm64@15.3.5':
optional: true
'@next/swc-darwin-x64@15.3.4':
'@next/swc-darwin-x64@15.3.5':
optional: true
'@next/swc-linux-arm64-gnu@15.3.4':
'@next/swc-linux-arm64-gnu@15.3.5':
optional: true
'@next/swc-linux-arm64-musl@15.3.4':
'@next/swc-linux-arm64-musl@15.3.5':
optional: true
'@next/swc-linux-x64-gnu@15.3.4':
'@next/swc-linux-x64-gnu@15.3.5':
optional: true
'@next/swc-linux-x64-musl@15.3.4':
'@next/swc-linux-x64-musl@15.3.5':
optional: true
'@next/swc-win32-arm64-msvc@15.3.4':
'@next/swc-win32-arm64-msvc@15.3.5':
optional: true
'@next/swc-win32-x64-msvc@15.3.4':
'@next/swc-win32-x64-msvc@15.3.5':
optional: true
'@nodelib/fs.scandir@2.1.5':
@ -4886,9 +4886,9 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-next@15.3.4(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3):
eslint-config-next@15.3.6(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3):
dependencies:
'@next/eslint-plugin-next': 15.3.4
'@next/eslint-plugin-next': 15.3.6
'@rushstack/eslint-patch': 1.12.0
'@typescript-eslint/eslint-plugin': 8.35.1(@typescript-eslint/parser@8.35.1(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3)
'@typescript-eslint/parser': 8.35.1(eslint@9.30.0(jiti@2.4.2))(typescript@5.8.3)
@ -5618,11 +5618,11 @@ snapshots:
negotiator@1.0.0: {}
next-intl@4.3.4(next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3):
next-intl@4.3.4(next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(typescript@5.8.3):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
negotiator: 1.0.0
next: 15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next: 15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
use-intl: 4.3.4(react@19.1.0)
optionalDependencies:
@ -5633,9 +5633,9 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
next@15.3.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
next@15.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 15.3.4
'@next/env': 15.3.6
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@ -5645,14 +5645,14 @@ snapshots:
react-dom: 19.1.0(react@19.1.0)
styled-jsx: 5.1.6(react@19.1.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.3.4
'@next/swc-darwin-x64': 15.3.4
'@next/swc-linux-arm64-gnu': 15.3.4
'@next/swc-linux-arm64-musl': 15.3.4
'@next/swc-linux-x64-gnu': 15.3.4
'@next/swc-linux-x64-musl': 15.3.4
'@next/swc-win32-arm64-msvc': 15.3.4
'@next/swc-win32-x64-msvc': 15.3.4
'@next/swc-darwin-arm64': 15.3.5
'@next/swc-darwin-x64': 15.3.5
'@next/swc-linux-arm64-gnu': 15.3.5
'@next/swc-linux-arm64-musl': 15.3.5
'@next/swc-linux-x64-gnu': 15.3.5
'@next/swc-linux-x64-musl': 15.3.5
'@next/swc-win32-arm64-msvc': 15.3.5
'@next/swc-win32-x64-msvc': 15.3.5
sharp: 0.34.2
transitivePeerDependencies:
- '@babel/core'

View File

@ -13,7 +13,14 @@ import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { useUppyUpload } from "@/hooks/useUppyUpload";
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
import {
abortMultipartUploadByAlias,
completeMultipartUploadByAlias,
createMultipartUploadByAlias,
getMultipartPartUrlByAlias,
getPresignedUrlForUploadByAlias,
registerFileUploadByAlias,
} from "@/http/endpoints";
import { formatFileSize } from "@/utils/format-file-size";
import { UPLOAD_CONFIG } from "../constants";
import { FileUploadSectionProps } from "../types";
@ -102,6 +109,41 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
onUploadSuccess?.();
}
},
// Custom multipart functions for reverse share uploads (no auth required)
customMultipartFunctions: {
createMultipartUpload: async (filename: string, extension: string) => {
const response = await createMultipartUploadByAlias(
alias,
{ filename, extension },
password ? { password } : undefined
);
return response.data;
},
getMultipartPartUrl: async (uploadId: string, objectName: string, partNumber: string) => {
const response = await getMultipartPartUrlByAlias(alias, { uploadId, objectName, partNumber, password });
return response.data;
},
completeMultipartUpload: async (
uploadId: string,
objectName: string,
parts: Array<{ PartNumber: number; ETag: string }>
) => {
const response = await completeMultipartUploadByAlias(
alias,
{ uploadId, objectName, parts },
password ? { password } : undefined
);
return response.data;
},
abortMultipartUpload: async (uploadId: string, objectName: string) => {
const response = await abortMultipartUploadByAlias(
alias,
{ uploadId, objectName },
password ? { password } : undefined
);
return response.data;
},
},
});
const onDrop = useCallback(

View File

@ -45,9 +45,10 @@ async function getBaseUrl(): Promise<string> {
return `${protocol}://${host}`;
}
export async function generateMetadata({ params }: { params: { alias: string } }): Promise<Metadata> {
export async function generateMetadata({ params }: { params: Promise<{ alias: string }> }): Promise<Metadata> {
const t = await getTranslations();
const metadata = await getReverseShareMetadata(params.alias);
const resolvedParams = await params;
const metadata = await getReverseShareMetadata(resolvedParams.alias);
const appInfo = await getAppInfo();
const title = metadata?.name || t("reverseShares.upload.metadata.title");
@ -58,7 +59,7 @@ export async function generateMetadata({ params }: { params: { alias: string } }
: t("reverseShares.upload.metadata.description"));
const baseUrl = await getBaseUrl();
const shareUrl = `${baseUrl}/r/${params.alias}`;
const shareUrl = `${baseUrl}/r/${resolvedParams.alias}`;
return {
title,

View File

@ -335,64 +335,71 @@ export function usePublicShare() {
}
try {
// Prepare all items for the share-specific bulk download
const allItems: Array<{
objectName?: string;
name: string;
id?: string;
type?: "file" | "folder";
}> = [];
if (share.files) {
share.files.forEach((file) => {
if (!file.folderId) {
allItems.push({
objectName: file.objectName,
name: file.name,
type: "file",
});
}
});
}
if (share.folders) {
const folderIds = new Set(share.folders.map((f) => f.id));
share.folders.forEach((folder) => {
if (!folder.parentId || !folderIds.has(folder.parentId)) {
allItems.push({
id: folder.id,
name: folder.name,
type: "folder",
});
}
});
}
if (allItems.length === 0) {
toast.error(t("shareManager.noFilesToDownload"));
return;
}
const loadingToast = toast.loading(t("shareManager.creatingZip"));
try {
// Get presigned URLs for all files
const downloadItems = await Promise.all(
allItems
.filter((item) => item.type === "file" && item.objectName)
.map(async (item) => {
// Helper function to get all files in a folder recursively with paths
const getFolderFilesWithPath = (
targetFolderId: string,
currentPath: string = ""
): Array<{ file: any; path: string }> => {
const filesWithPath: Array<{ file: any; path: string }> = [];
// Get direct files in this folder
const directFiles = share.files?.filter((f) => f.folderId === targetFolderId) || [];
directFiles.forEach((file) => {
filesWithPath.push({ file, path: currentPath });
});
// Get subfolders and process them recursively
const subfolders = share.folders?.filter((f) => f.parentId === targetFolderId) || [];
for (const subfolder of subfolders) {
const subfolderPath = currentPath ? `${currentPath}/${subfolder.name}` : subfolder.name;
filesWithPath.push(...getFolderFilesWithPath(subfolder.id, subfolderPath));
}
return filesWithPath;
};
const allFilesToDownload: Array<{ url: string; name: string }> = [];
// Get presigned URLs for root level files (not in any folder)
const rootFiles = share.files?.filter((f) => !f.folderId) || [];
const rootFileItems = await Promise.all(
rootFiles.map(async (file) => {
const url = await getCachedDownloadUrl(
file.objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: file.name,
};
})
);
allFilesToDownload.push(...rootFileItems);
// Get presigned URLs for files in root level folders
const rootFolders = share.folders?.filter((f) => !f.parentId) || [];
for (const folder of rootFolders) {
const folderFilesWithPath = getFolderFilesWithPath(folder.id, folder.name);
const folderFileItems = await Promise.all(
folderFilesWithPath.map(async ({ file, path }) => {
const url = await getCachedDownloadUrl(
item.objectName!,
file.objectName,
password ? { headers: { "x-share-password": password } } : undefined
);
return {
url,
name: item.name,
name: path ? `${path}/${file.name}` : file.name,
};
})
);
);
allFilesToDownload.push(...folderFileItems);
}
if (downloadItems.length === 0) {
if (allFilesToDownload.length === 0) {
toast.dismiss(loadingToast);
toast.error(t("shareManager.noFilesToDownload"));
return;
@ -401,7 +408,7 @@ export function usePublicShare() {
// Create ZIP with all files
const { downloadFilesAsZip } = await import("@/utils/zip-download");
const zipName = `${share.name || t("shareManager.defaultShareName")}.zip`;
await downloadFilesAsZip(downloadItems, zipName);
await downloadFilesAsZip(allFilesToDownload, zipName);
toast.dismiss(loadingToast);
toast.success(t("shareManager.zipDownloadSuccess"));

View File

@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server";
interface LayoutProps {
children: React.ReactNode;
params: { alias: string };
params: Promise<{ alias: string }>;
}
async function getShareMetadata(alias: string) {
@ -50,9 +50,10 @@ async function getBaseUrl(): Promise<string> {
return `${protocol}://${host}`;
}
export async function generateMetadata({ params }: { params: { alias: string } }): Promise<Metadata> {
export async function generateMetadata({ params }: { params: Promise<{ alias: string }> }): Promise<Metadata> {
const t = await getTranslations();
const metadata = await getShareMetadata(params.alias);
const resolvedParams = await params;
const metadata = await getShareMetadata(resolvedParams.alias);
const appInfo = await getAppInfo();
const title = metadata?.name || t("share.pageTitle");
@ -63,7 +64,7 @@ export async function generateMetadata({ params }: { params: { alias: string } }
: appInfo.appDescription || t("share.metadata.defaultDescription"));
const baseUrl = await getBaseUrl();
const shareUrl = `${baseUrl}/s/${params.alias}`;
const shareUrl = `${baseUrl}/s/${resolvedParams.alias}`;
return {
title,

View File

@ -121,7 +121,7 @@ export function TwoFactorForm() {
<CardDescription>{status.enabled ? t("twoFactor.enabled") : t("twoFactor.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-0">
<div>
<p className="font-medium">
{t("twoFactor.status.label")}{" "}
@ -133,19 +133,29 @@ export function TwoFactorForm() {
</p>
)}
</div>
<div className="flex gap-2">
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
{status.enabled ? (
<>
<Button variant="outline" onClick={generateNewBackupCodes} disabled={isLoading}>
<Button
variant="outline"
onClick={generateNewBackupCodes}
disabled={isLoading}
className="w-full sm:w-auto"
>
<IconKey className="h-4 w-4" />
{t("twoFactor.backupCodes.generateNew")}
</Button>
<Button variant="destructive" onClick={() => setIsDisableModalOpen(true)} disabled={isLoading}>
<Button
variant="destructive"
onClick={() => setIsDisableModalOpen(true)}
disabled={isLoading}
className="w-full sm:w-auto"
>
{t("twoFactor.buttons.disable2FA")}
</Button>
</>
) : (
<Button onClick={startSetup} disabled={isLoading}>
<Button onClick={startSetup} disabled={isLoading} className="w-full sm:w-auto">
<IconShield className="h-4 w-4" />
{t("twoFactor.buttons.enable2FA")}
</Button>

View File

@ -1,56 +1,33 @@
"use client"
"use client";
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
function ContextMenuSubTrigger({
@ -59,7 +36,7 @@ function ContextMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
@ -74,13 +51,10 @@ function ContextMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
@ -90,13 +64,10 @@ function ContextMenuSubContent({
)}
{...props}
/>
)
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
@ -108,7 +79,7 @@ function ContextMenuContent({
{...props}
/>
</ContextMenuPrimitive.Portal>
)
);
}
function ContextMenuItem({
@ -117,8 +88,8 @@ function ContextMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
@ -131,7 +102,7 @@ function ContextMenuItem({
)}
{...props}
/>
)
);
}
function ContextMenuCheckboxItem({
@ -157,7 +128,7 @@ function ContextMenuCheckboxItem({
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
);
}
function ContextMenuRadioItem({
@ -181,7 +152,7 @@ function ContextMenuRadioItem({
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
);
}
function ContextMenuLabel({
@ -189,48 +160,36 @@ function ContextMenuLabel({
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
className={cn("text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
)
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
);
}
export {
@ -249,4 +208,4 @@ export {
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
};

View File

@ -4,7 +4,6 @@ import Link from "next/link";
import { useTranslations } from "next-intl";
import { useSecureConfigValue } from "@/hooks/use-secure-configs";
import packageJson from "../../../package.json";
const { version } = packageJson;

View File

@ -5,6 +5,7 @@ const apiInstance = axios.create({
"Content-Type": "application/json",
},
withCredentials: true,
timeout: 120000, // 2 minutes timeout for API calls
});
export default apiInstance;

View File

@ -394,6 +394,7 @@ export function useEnhancedFileManager(
await registerFolder(folderData);
toast.success(t("folderActions.folderCreated"));
setCreateFolderModalOpen(false);
await onRefresh();
} catch (error) {
console.error("Error creating folder:", error);
toast.error(t("folderActions.createFolderError"));
@ -406,6 +407,7 @@ export function useEnhancedFileManager(
await updateFolder(folderId, { name: newName, description });
toast.success(t("folderActions.folderRenamed"));
setFolderToRename(null);
await onRefresh();
} catch (error) {
console.error("Error renaming folder:", error);
toast.error(t("folderActions.renameFolderError"));
@ -414,7 +416,6 @@ export function useEnhancedFileManager(
const handleFolderDelete = async (folderId: string) => {
try {
// Optimistic update - remove from UI immediately
if (handleImmediateUpdate) {
handleImmediateUpdate(folderId, "folder", "__DELETE__" as any);
}

View File

@ -4,6 +4,7 @@ import { toast } from "sonner";
import { getCachedDownloadUrl, getCachedReverseShareDownloadUrl } from "@/lib/download-url-cache";
import { getFileExtension, getFileType, type FileType } from "@/utils/file-types";
import { getMimeType } from "@/utils/mime-types";
interface FilePreviewState {
previewUrl: string | null;
@ -73,35 +74,49 @@ export function useFilePreview({ file, isOpen, isReverseShare = false, sharePass
});
}, []);
const loadVideoPreview = useCallback(async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
const loadVideoPreview = useCallback(
async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
// Create a new blob with the correct MIME type based on file extension
// This fixes issues when the server returns application/octet-stream
const mimeType = getMimeType(file.name);
const typedBlob = new Blob([blob], { type: mimeType });
const blobUrl = URL.createObjectURL(typedBlob);
setState((prev) => ({ ...prev, videoBlob: blobUrl }));
} catch {
setState((prev) => ({ ...prev, previewUrl: url }));
}
},
[file.name]
);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setState((prev) => ({ ...prev, videoBlob: blobUrl }));
} catch {
setState((prev) => ({ ...prev, previewUrl: url }));
}
}, []);
const loadAudioPreview = useCallback(
async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const loadAudioPreview = useCallback(async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
// Create a new blob with the correct MIME type based on file extension
// This fixes issues when the server returns application/octet-stream
const mimeType = getMimeType(file.name);
const typedBlob = new Blob([blob], { type: mimeType });
const blobUrl = URL.createObjectURL(typedBlob);
setState((prev) => ({ ...prev, previewUrl: blobUrl }));
} catch {
setState((prev) => ({ ...prev, previewUrl: url }));
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
setState((prev) => ({ ...prev, previewUrl: blobUrl }));
} catch {
setState((prev) => ({ ...prev, previewUrl: url }));
}
}, []);
},
[file.name]
);
const handlePdfLoadError = useCallback(() => {
setState((prev) => {

View File

@ -12,6 +12,20 @@ import {
getMultipartPartUrl,
} from "@/http/endpoints/files";
/**
* Custom multipart upload functions for non-authenticated uploads (e.g., reverse shares)
*/
export interface CustomMultipartFunctions {
createMultipartUpload: (filename: string, extension: string) => Promise<{ uploadId: string; objectName: string }>;
getMultipartPartUrl: (uploadId: string, objectName: string, partNumber: string) => Promise<{ url: string }>;
completeMultipartUpload: (
uploadId: string,
objectName: string,
parts: Array<{ PartNumber: number; ETag: string }>
) => Promise<any>;
abortMultipartUpload: (uploadId: string, objectName: string) => Promise<any>;
}
/**
* Options for the useUppyUpload hook
*/
@ -49,6 +63,11 @@ export interface UseUppyUploadOptions {
* Optional folder context for file organization
*/
currentFolderId?: string;
/**
* Optional custom multipart upload functions (for unauthenticated uploads like reverse shares)
*/
customMultipartFunctions?: CustomMultipartFunctions;
}
/**
@ -85,6 +104,7 @@ export function useUppyUpload(options: UseUppyUploadOptions) {
const onAfterUploadRef = useRef(options.onAfterUpload);
const getPresignedUrlRef = useRef(options.getPresignedUrl);
const onSuccessRef = useRef(options.onSuccess);
const customMultipartRef = useRef(options.customMultipartFunctions);
// Update refs when callbacks change
useEffect(() => {
@ -93,6 +113,7 @@ export function useUppyUpload(options: UseUppyUploadOptions) {
onAfterUploadRef.current = options.onAfterUpload;
getPresignedUrlRef.current = options.getPresignedUrl;
onSuccessRef.current = options.onSuccess;
customMultipartRef.current = options.customMultipartFunctions;
}, [options]);
// Initialize Uppy instance only once
@ -109,8 +130,8 @@ export function useUppyUpload(options: UseUppyUploadOptions) {
// Files <100MB: Use simple PUT upload
// Files ≥100MB: Use multipart chunked upload
uppy.use(AwsS3, {
limit: 6, // Allow 6 concurrent part uploads
getChunkSize: () => 8 * 1024 * 1024, // 8MB chunk size
limit: 3, // Allow 3 concurrent part uploads (reduced for stability)
getChunkSize: () => 5 * 1024 * 1024, // 5MB chunk size (reduced for better performance)
shouldUseMultipart: (file: any) => {
const fileSize = file.size || 0;
const useMultipart = fileSize >= UPLOAD_CONFIG.MULTIPART_THRESHOLD;
@ -184,12 +205,21 @@ export function useUppyUpload(options: UseUppyUploadOptions) {
const filename = objectName.replace(`.${extension}`, "");
// 3. Create multipart upload on backend
const response = await createMultipartUpload({
filename,
extension,
});
let response;
if (customMultipartRef.current) {
// Use custom multipart functions (e.g., for reverse shares)
response = await customMultipartRef.current.createMultipartUpload(filename, extension);
} else {
// Use default authenticated multipart upload
response = (
await createMultipartUpload({
filename,
extension,
})
).data;
}
const { uploadId, objectName: actualObjectName } = response.data;
const { uploadId, objectName: actualObjectName } = response;
// Store metadata
uppy.setFileMeta(file.id, {
@ -221,15 +251,24 @@ export function useUppyUpload(options: UseUppyUploadOptions) {
const { uploadId, key, partNumber } = partData;
try {
const response = await getMultipartPartUrl({
uploadId,
objectName: key,
partNumber: partNumber.toString(),
});
let response;
if (customMultipartRef.current) {
// Use custom multipart functions (e.g., for reverse shares)
response = await customMultipartRef.current.getMultipartPartUrl(uploadId, key, partNumber.toString());
} else {
// Use default authenticated multipart upload
response = (
await getMultipartPartUrl({
uploadId,
objectName: key,
partNumber: partNumber.toString(),
})
).data;
}
// Return the signed URL object directly - Uppy expects { url, headers }
return {
url: response.data.url,
url: response.url,
headers: {},
};
} catch (error) {
@ -244,11 +283,17 @@ export function useUppyUpload(options: UseUppyUploadOptions) {
const meta = file.meta as { objectName: string };
try {
await completeMultipartUpload({
uploadId,
objectName: meta.objectName || key,
parts,
});
if (customMultipartRef.current) {
// Use custom multipart functions (e.g., for reverse shares)
await customMultipartRef.current.completeMultipartUpload(uploadId, meta.objectName || key, parts);
} else {
// Use default authenticated multipart upload
await completeMultipartUpload({
uploadId,
objectName: meta.objectName || key,
parts,
});
}
return {};
} catch (error) {
@ -262,10 +307,16 @@ export function useUppyUpload(options: UseUppyUploadOptions) {
const meta = file.meta as { objectName: string };
try {
await abortMultipartUpload({
uploadId,
objectName: meta.objectName || key,
});
if (customMultipartRef.current) {
// Use custom multipart functions (e.g., for reverse shares)
await customMultipartRef.current.abortMultipartUpload(uploadId, meta.objectName || key);
} else {
// Use default authenticated multipart upload
await abortMultipartUpload({
uploadId,
objectName: meta.objectName || key,
});
}
} catch (error) {
console.error("[Upload:Multipart] Failed to abort multipart upload:", error);
// Don't throw - abort is cleanup, shouldn't fail the operation

View File

@ -273,3 +273,66 @@ export const copyReverseShareFileToUserFiles = <TData = any>(
): Promise<TData> => {
return apiInstance.post(`/api/reverse-shares/files/${fileId}/copy`, undefined, options);
};
/**
* Create a multipart upload for reverse share (public endpoint)
* @summary Create Multipart Upload for Reverse Share (Public)
*/
export const createMultipartUploadByAlias = <TData = any>(
alias: string,
body: { filename: string; extension: string },
params?: { password?: string },
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.post(`/api/reverse-shares/alias/${alias}/multipart/create`, body, {
...options,
params: { ...params, ...options?.params },
});
};
/**
* Get presigned URL for a multipart upload part for reverse share (public endpoint)
* @summary Get Multipart Part URL for Reverse Share (Public)
*/
export const getMultipartPartUrlByAlias = <TData = any>(
alias: string,
params: { uploadId: string; objectName: string; partNumber: string; password?: string },
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.get(`/api/reverse-shares/alias/${alias}/multipart/part-url`, {
...options,
params: { ...params, ...options?.params },
});
};
/**
* Complete a multipart upload for reverse share (public endpoint)
* @summary Complete Multipart Upload for Reverse Share (Public)
*/
export const completeMultipartUploadByAlias = <TData = any>(
alias: string,
body: { uploadId: string; objectName: string; parts: Array<{ PartNumber: number; ETag: string }> },
params?: { password?: string },
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.post(`/api/reverse-shares/alias/${alias}/multipart/complete`, body, {
...options,
params: { ...params, ...options?.params },
});
};
/**
* Abort a multipart upload for reverse share (public endpoint)
* @summary Abort Multipart Upload for Reverse Share (Public)
*/
export const abortMultipartUploadByAlias = <TData = any>(
alias: string,
body: { uploadId: string; objectName: string },
params?: { password?: string },
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.post(`/api/reverse-shares/alias/${alias}/multipart/abort`, body, {
...options,
params: { ...params, ...options?.params },
});
};

View File

@ -38,20 +38,42 @@ if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
exit 1
fi
# Configure storage client (mc) - ALWAYS reconfigure with current password
# Configure storage client (mc) - Run as target UID/GID
echo "[STORAGE-SYSTEM-SETUP] Configuring storage client..."
mc alias set palmr-local http://127.0.0.1:9379 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" 2>/dev/null || {
echo "[STORAGE-SYSTEM-SETUP] ✗ Failed to configure storage client"
exit 1
# Get target UID/GID from environment or default
TARGET_UID=${PALMR_UID:-${MINIO_UID:-1001}}
TARGET_GID=${PALMR_GID:-${MINIO_GID:-1001}}
# Ensure home directory exists with correct ownership
if [ "$(id -u)" = "0" ]; then
mkdir -p /home/palmr/.mc
chown -R $TARGET_UID:$TARGET_GID /home/palmr 2>/dev/null || true
fi
# Run mc commands as target user
run_as_target() {
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID "$@"
else
"$@"
fi
}
# Configure with verbose error output
if ! run_as_target mc alias set palmr-local http://127.0.0.1:9379 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" 2>&1; then
echo "[STORAGE-SYSTEM-SETUP] ✗ Failed to configure storage client"
echo "[STORAGE-SYSTEM-SETUP] Debug: UID/GID=$TARGET_UID:$TARGET_GID, User=$(whoami)"
exit 1
fi
# Create bucket (idempotent - won't fail if exists)
echo "[STORAGE-SYSTEM-SETUP] Ensuring storage bucket exists: $MINIO_BUCKET..."
if mc ls palmr-local/$MINIO_BUCKET > /dev/null 2>&1; then
if run_as_target mc ls palmr-local/$MINIO_BUCKET > /dev/null 2>&1; then
echo "[STORAGE-SYSTEM-SETUP] ✓ Bucket '$MINIO_BUCKET' already exists"
else
echo "[STORAGE-SYSTEM-SETUP] Creating bucket '$MINIO_BUCKET'..."
mc mb "palmr-local/$MINIO_BUCKET" 2>/dev/null || {
run_as_target mc mb "palmr-local/$MINIO_BUCKET" 2>/dev/null || {
echo "[STORAGE-SYSTEM-SETUP] ✗ Failed to create bucket"
exit 1
}
@ -60,7 +82,7 @@ fi
# Set bucket policy to private (always reapply)
echo "[STORAGE-SYSTEM-SETUP] Setting bucket policy..."
mc anonymous set none "palmr-local/$MINIO_BUCKET" 2>/dev/null || true
run_as_target mc anonymous set none "palmr-local/$MINIO_BUCKET" 2>/dev/null || true
# Save credentials for Palmr to use
echo "[STORAGE-SYSTEM-SETUP] Saving credentials to $MINIO_CREDENTIALS..."

View File

@ -38,12 +38,15 @@ if [ -n "$PALMR_UID" ] || [ -n "$PALMR_GID" ]; then
echo "🔧 Runtime UID/GID: $TARGET_UID:$TARGET_GID"
echo "🔐 Updating file ownership..."
chown -R $TARGET_UID:$TARGET_GID /app/palmr-app 2>/dev/null || echo "⚠️ Some ownership changes may have failed"
# Only chown application files (these are small and fast)
find /app/palmr-app -maxdepth 2 -exec chown $TARGET_UID:$TARGET_GID {} + 2>/dev/null || echo "⚠️ Some app ownership changes may have failed"
# Home directory is small, safe to chown
chown -R $TARGET_UID:$TARGET_GID /home/palmr 2>/dev/null || echo "⚠️ Some home directory ownership changes may have failed"
if [ -d "/app/server" ]; then
chown -R $TARGET_UID:$TARGET_GID /app/server 2>/dev/null || echo "⚠️ Some data directory ownership changes may have failed"
fi
# /app/server is handled by the main startup script with smart marker
# No need to duplicate the work here
echo "✅ UID/GID configuration completed"
fi
@ -59,10 +62,20 @@ echo "📁 Creating data directories..."
mkdir -p /app/server/prisma /app/server/uploads /app/server/temp-uploads
if [ "$(id -u)" = "0" ]; then
echo "🔐 Ensuring proper ownership for all operations..."
# Fix permissions for entire /app/server to allow migration and storage system operations
chown -R $TARGET_UID:$TARGET_GID /app/server 2>/dev/null || true
chmod -R 755 /app/server 2>/dev/null || true
echo "🔐 Ensuring proper ownership for critical files..."
# Ensure base directories exist and have correct ownership
chown $TARGET_UID:$TARGET_GID /app/server/uploads /app/server/temp-uploads 2>/dev/null || true
chmod 755 /app/server/uploads /app/server/temp-uploads 2>/dev/null || true
# Critical: Database files need read+write permissions
if [ -d "/app/server/prisma" ]; then
chown -R $TARGET_UID:$TARGET_GID /app/server/prisma 2>/dev/null || true
chmod -R 755 /app/server/prisma 2>/dev/null || true
# Ensure database file is writable
if [ -f "/app/server/prisma/palmr.db" ]; then
chmod 644 /app/server/prisma/palmr.db 2>/dev/null || true
fi
fi
fi
run_as_user() {

View File

@ -10,41 +10,39 @@ MINIO_USER="palmr"
echo "[STORAGE-SYSTEM] Initializing storage..."
# DYNAMIC: Detect palmr user's actual UID and GID
# This works with any Docker user configuration
MINIO_UID=$(id -u $MINIO_USER 2>/dev/null || echo "1001")
MINIO_GID=$(id -g $MINIO_USER 2>/dev/null || echo "1001")
# USE ENVIRONMENT VARIABLES: Allow runtime UID/GID configuration
# Falls back to palmr user's UID/GID if not specified
export MINIO_UID=${PALMR_UID:-$(id -u $MINIO_USER 2>/dev/null || echo "1001")}
export MINIO_GID=${PALMR_GID:-$(id -g $MINIO_USER 2>/dev/null || echo "1001")}
echo "[STORAGE-SYSTEM] Target user: $MINIO_USER (UID:$MINIO_UID, GID:$MINIO_GID)"
# CRITICAL: Fix permissions as root (supervisor runs this as root via user=root)
# This MUST happen before dropping to palmr user
# Ensure directory exists and has correct permissions
if [ "$(id -u)" = "0" ]; then
echo "[STORAGE-SYSTEM] Fixing permissions (running as root)..."
mkdir -p "$DATA_DIR"
# Clean metadata
# Clean metadata if exists
if [ -d "$DATA_DIR/.minio.sys" ]; then
echo "[STORAGE-SYSTEM] Cleaning metadata..."
rm -rf "$DATA_DIR/.minio.sys" 2>/dev/null || true
fi
# Ensure directory exists
mkdir -p "$DATA_DIR"
# CRITICAL: MinIO needs write access to ALL subdirectories for multipart uploads
# Check if ownership is correct before doing expensive recursive chown
CURRENT_OWNER=$(stat -c '%u:%g' "$DATA_DIR" 2>/dev/null || stat -f '%u:%g' "$DATA_DIR" 2>/dev/null || echo "0:0")
TARGET_OWNER="${MINIO_UID}:${MINIO_GID}"
# FIX: Change ownership to palmr (using detected UID:GID)
chown -R ${MINIO_UID}:${MINIO_GID} "$DATA_DIR" 2>/dev/null || {
echo "[STORAGE-SYSTEM] ⚠️ chown -R failed, trying non-recursive..."
chown ${MINIO_UID}:${MINIO_GID} "$DATA_DIR" 2>/dev/null || true
}
if [ "$CURRENT_OWNER" != "$TARGET_OWNER" ] || [ -n "$(find "$DATA_DIR" -type f -o -type d ! -user ${MINIO_UID} 2>/dev/null | head -1)" ]; then
echo "[STORAGE-SYSTEM] Fixing storage permissions recursively..."
chown -R ${MINIO_UID}:${MINIO_GID} "$DATA_DIR" 2>/dev/null || true
echo "[STORAGE-SYSTEM] ✓ Permissions fixed (owner: ${MINIO_UID}:${MINIO_GID})"
else
echo "[STORAGE-SYSTEM] ✓ Permissions already correct"
fi
chmod 755 "$DATA_DIR" 2>/dev/null || true
# Force filesystem sync to ensure changes are visible immediately
sync
echo "[STORAGE-SYSTEM] ✓ Permissions fixed (owner: ${MINIO_UID}:${MINIO_GID})"
else
echo "[STORAGE-SYSTEM] ⚠️ WARNING: Not running as root, cannot fix permissions"
echo "[STORAGE-SYSTEM] ⚠️ WARNING: Not running as root"
fi
# Verify directory is writable (test as palmr with detected UID:GID)

View File

@ -23,7 +23,7 @@ startsecs=3
[program:minio-setup]
command=/bin/sh /app/minio-setup.sh
directory=/app
user=palmr
user=root
autostart=true
autorestart=unexpected
exitcodes=0
@ -31,7 +31,7 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=HOME="/home/palmr"
environment=HOME="/root"
priority=60
startsecs=0

View File

@ -1,6 +1,6 @@
{
"name": "palmr-monorepo",
"version": "3.3.1-beta",
"version": "3.3.2-beta",
"description": "Palmr monorepo with Husky configuration",
"private": true,
"packageManager": "pnpm@10.6.0",