Compare commits

...

43 Commits

Author SHA1 Message Date
Daniel Luiz Alves
c265b8e08d v3.0.0-beta.9 (#90) 2025-06-20 16:33:04 -03:00
Daniel Luiz Alves
d0173a0bf9 refactor: optimize file icon rendering in UploadFileModal
- Consolidated file icon logic by introducing a new renderFileIcon function that utilizes the getFileIcon utility for improved clarity and maintainability.
- Removed redundant icon imports and streamlined the icon rendering process based on file names, enhancing code efficiency.
2025-06-20 16:09:13 -03:00
Daniel Luiz Alves
0d346b75cc refactor: simplify FilePreviewModal by utilizing useFilePreview hook
- Replaced complex state management and effect hooks in FilePreviewModal with a custom useFilePreview hook for improved readability and maintainability.
- Integrated FilePreviewRenderer component to handle different file types and rendering logic, enhancing the modularity of the code.
- Updated file icon mappings in file-icons.tsx to include additional file types and improve visual representation in the UI.
2025-06-20 15:37:00 -03:00
Daniel Luiz Alves
0a65917cbf refactor: update FilePreviewModal to handle text file previews
- Renamed jsonContent state to textContent for clarity and updated related logic to support various text file types.
- Implemented a new loadTextPreview function to handle text and JSON file previews, ensuring proper formatting and error handling.
- Enhanced file type detection to include a broader range of text file extensions, improving the preview functionality for users.
2025-06-20 15:14:35 -03:00
Daniel Luiz Alves
f651f50180 feat: enhance file upload and preview functionality (#89) 2025-06-20 14:44:43 -03:00
Daniel Luiz Alves
1125665bb1 feat: enhance file upload and preview functionality
- Improved the uploadSmallFile method to handle various request body types (buffer, string, object, stream) more effectively.
- Added error handling for unsupported request body types.
- Implemented JSON file preview capability in FilePreviewModal, allowing users to view formatted JSON content.
- Updated localization files to include "retry" messages in multiple languages for better user experience during upload errors.
2025-06-20 14:43:27 -03:00
Daniel Luiz Alves
b65aac3044 refactor: update authentication logic to support email or username (#88) 2025-06-20 13:46:10 -03:00
Daniel Luiz Alves
a865aabed0 refactor: update authentication logic to support email or username
- Modified the login schema to accept either an email or username for user authentication.
- Updated the AuthService to find users by email or username.
- Adjusted localization files to include new labels and placeholders for email or username input across multiple languages.
- Refactored the login form component to reflect the changes in the schema and improve user experience.
2025-06-20 13:45:37 -03:00
Daniel Luiz Alves
561e8faf33 refactor: remove outdated comment in FilePreviewModal
- Eliminated a redundant comment regarding the direct link approach in the file download logic to enhance code clarity and maintainability.
2025-06-20 10:29:08 -03:00
Daniel Luiz Alves
6445b0ce3e refactor: streamline file download logic in FilePreviewModal
- Updated the file download process to use a direct link approach, eliminating unnecessary fetch and blob creation steps.
- Improved code clarity by simplifying the download mechanism while maintaining functionality.
2025-06-20 10:28:44 -03:00
Daniel Luiz Alves
90cd3333cb Improve disk space detection (#87) 2025-06-20 10:19:15 -03:00
Daniel Luiz Alves
2ca0db70c3 localization: add loading and error messages for storage usage in multiple languages
- Enhanced localization files for various languages by adding loading states and detailed error messages related to storage information retrieval.
- Updated translations for "available" and included new keys for "loading," "retry," and various error scenarios to improve user experience during storage operations.
2025-06-20 10:18:33 -03:00
Daniel Luiz Alves
28697fa270 refactor: clean up comments and improve readability in various modules
- Removed unnecessary comments from timeout configuration, OIDC routes, reverse share routes, and other modules to enhance code clarity.
- Streamlined the code by eliminating redundant comments that do not add value to the understanding of the logic.
- Improved overall maintainability by focusing on concise and meaningful code structure.
2025-06-20 10:10:06 -03:00
Daniel Luiz Alves
d739c1b213 refactor: replace ShareFilePreviewModal with FilePreviewModal in files table component
- Updated the files table component to use FilePreviewModal for file previews.
- Removed the ShareFilePreviewModal component as it is no longer needed.
2025-06-20 09:55:03 -03:00
Daniel Luiz Alves
25a0c39135 docs: added instructions for Zitadel (#85) 2025-06-20 09:51:12 -03:00
Daniel Luiz Alves
185fa4c191 fix: change Docker build command from --push to --load
- Updated the Docker build command in build-docker.sh to use --load instead of --push, allowing for local image loading without pushing to a registry.
2025-06-20 09:48:42 -03:00
Daniel Luiz Alves
9dfb034c2e enhance: improve disk space detection and error handling in storage module
- Refactored disk space retrieval logic to support multiple commands based on the operating system.
- Added detailed error handling for disk space detection failures, including specific messages for system configuration issues.
- Updated API responses to provide clearer error messages to the frontend.
- Enhanced the dashboard UI to display loading states and error messages related to disk space retrieval, with retry functionality.
- Improved type definitions to accommodate new error handling in the dashboard components.
2025-06-20 09:48:00 -03:00
ruohki
936a2b71c7 docs: added instructions for Zitadel 2025-06-20 13:10:55 +02:00
Daniel Luiz Alves
cd14c28be1 refactor: simplify Docker environment detection for file storage paths (#77) 2025-06-19 03:02:31 -03:00
Daniel Luiz Alves
3c084a6686 refactor: simplify Docker environment detection for file storage paths
- Replaced manual Docker detection logic with a utility constant for determining if the application is running in a container.
- Updated file storage paths in both server and filesystem storage provider to use the new constant for improved readability and maintainability.
2025-06-19 02:49:47 -03:00
Daniel Luiz Alves
6a1381684b refactor: replace FilePreviewModal with ShareFilePreviewModal (#76) 2025-06-19 02:01:07 -03:00
Daniel Luiz Alves
dc20770fe6 refactor: replace FilePreviewModal with ShareFilePreviewModal in files table component
- Updated the files table component to use ShareFilePreviewModal for file previews.
- Removed the unused import of FilePreviewModal and added the new import for ShareFilePreviewModal.
2025-06-19 01:46:50 -03:00
Daniel Luiz Alves
6e526f7f88 fix: update email transport secure (#75) 2025-06-19 00:51:27 -03:00
Daniel Luiz Alves
858852c8cd refactor: remove unused import from email service 2025-06-19 00:50:09 -03:00
Daniel Luiz Alves
363dedbb2c Update service.ts (#74) 2025-06-19 00:49:27 -03:00
TerrifiedBug
cd215c79b8 Update service.ts
Fix nodemailer secure flag for STARTTLS
2025-06-18 23:45:42 +01:00
Daniel Luiz Alves
98586efbcd v3.0.0-beta.5 (#72) 2025-06-18 18:31:09 -03:00
Daniel Luiz Alves
c724e644c7 fix: update notification endpoint and include request body in API call
- Changed the API endpoint for notifying recipients to include the shareId directly in the URL.
- Added the request body to the fetch call to ensure proper data is sent with the notification request.
- Set the Content-Type header to application/json for the request.
2025-06-18 18:14:20 -03:00
Daniel Luiz Alves
555ff18a87 feat: implement Docker compatibility for file storage paths (#71) 2025-06-18 18:06:51 -03:00
Daniel Luiz Alves
5100e1591b feat: implement Docker compatibility for file storage paths
- Added checks to determine if the application is running in a Docker environment.
- Updated file storage paths to use `/app/server` in Docker and the current working directory for local development.
- Ensured consistent directory creation for uploads and temporary chunks across different environments.
2025-06-18 18:05:46 -03:00
Daniel Luiz Alves
6de29bbf07 fix: standardize environment variable imports and enhance user auth (#69) 2025-06-18 17:08:59 -03:00
Daniel Luiz Alves
39c47be940 fix: standardize environment variable imports and enhance user authentication error handling
- Updated imports for environment variables in auth and email services to ensure consistency.
- Improved error handling in user routes to provide more specific responses for unauthorized access and internal server errors.
2025-06-18 16:57:11 -03:00
Daniel Luiz Alves
76d96816bc v3.0.0-beta.3 (#68) 2025-06-18 16:19:24 -03:00
Daniel Luiz Alves
b3e7658a76 feat: enhance authentication flow and improve database setup script (#67) 2025-06-18 15:32:41 -03:00
Daniel Luiz Alves
61a579aeb3 feat: enhance authentication flow and improve database setup script
- Added a check for first user access in the authentication context to handle initial user setup.
- Updated the server start script to ensure proper ownership and permissions for database operations, enhancing compatibility with Docker environments.
- Refactored database seeding and configuration checks to run as the target user, preventing permission issues during setup.
2025-06-18 15:32:12 -03:00
Daniel Luiz Alves
cc9c375774 feat: add reverse proxy support (#66) 2025-06-18 12:44:39 -03:00
Daniel Luiz Alves
016006ba3d fix: storage calculation when running within docker (#65) 2025-06-18 12:44:20 -03:00
ruohki
cbc567c6a8 fixed logic error 2025-06-18 17:26:23 +02:00
Daniel Luiz Alves
25b4d886f7 docs: Update reverse proxy configuration to address SQLite "readonly database" error
- Added guidance for configuring proper UID/GID permissions to resolve SQLite issues with bind mounts.
- Included a note on checking host UID/GID and linked to detailed setup documentation for clarity.
2025-06-18 12:23:00 -03:00
ruohki
98953e042b check if runs within docker to pick storage loc 2025-06-18 17:16:11 +02:00
Daniel Luiz Alves
9e06a67593 docs: remove outdated Nginx configuration from reverse proxy documentation
- Eliminated the Nginx HTTP configuration section for reverse proxies without HTTPS/SSL to streamline the documentation.
- Maintained focus on the SECURE_SITE variable and Docker Compose setup for clarity in reverse proxy configurations.
2025-06-18 12:14:56 -03:00
Daniel Luiz Alves
9682f96905 docs: reverse proxy documentation to streamline Docker Compose example
- Removed outdated Docker Compose configuration for the Palmr service.
- Retained the SECURE_SITE environment variable setting for clarity.
- Updated documentation to emphasize HTTP security considerations.
2025-06-18 12:14:05 -03:00
Daniel Luiz Alves
d2c69c3b36 feat: Add SECURE_SITE configuration and reverse proxy documentation
- Introduced the SECURE_SITE environment variable to control cookie security settings based on deployment context.
- Updated Dockerfile to log SECURE_SITE status during application startup.
- Enhanced documentation with a new guide on reverse proxy configuration, detailing the use of SECURE_SITE for secure cookie handling.
- Adjusted authentication and email services to utilize SECURE_SITE for secure connections.
- Updated frontend components to set cookie security based on the current protocol.
2025-06-18 12:10:54 -03:00
82 changed files with 2431 additions and 715 deletions

View File

@@ -132,6 +132,7 @@ set -e
echo "Starting Palmr Application..."
echo "Storage Mode: \${ENABLE_S3:-false}"
echo "Secure Site: \${SECURE_SITE:-false}"
echo "Database: SQLite"
# Set global environment variables

View File

@@ -15,6 +15,7 @@
"configuring-smtp",
"available-languages",
"uid-gid-configuration",
"reverse-proxy-configuration",
"password-reset-without-smtp",
"oidc-authentication",
"---Developers---",

View File

@@ -137,6 +137,22 @@ The setup process varies depending on your chosen identity provider. Here are ex
![Identity Provider Setup](/assets/v3/oidc/provider-setup.png)
### Zitadel
1. **Create New ProjectApp**: In your desired Zitadel project, create a new application
2. **Name and Type**: Give your application a name and choose **WEB** as the application type
3. **Authentication Method**: Choose Code
4. **Set Redirect URI**: Add your Palmr callback URL to valid redirect URIs
5. **Finish**: After reviewing the configuration create the application
6. **Copy the client ID and client Secrat**: Copy the client id paste it into the **Client ID** of your Palmr OIDC condiguration Form, repeat for the client secret and paste it into the **Client Secret** field
7. **Obtain your Provider URL**: In your Zitadel application go to **URLs** and copy the **Authorization Endpoint (remove the /authorize from that url)** e.g. https://auth.example.com/oauth/v2
**Configuration values:**
- **Issuer URL**: Depends on your Zitadel installation and project. Example: `https://auth.example.com/oauth/v2`
- **Scope**: `openid profile email`
![Zitadel Identity Provider Setup](/assets/v3/oidc/zitadel-provider-setup.png)
---
## Testing OIDC configuration

View File

@@ -56,6 +56,7 @@ services:
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
ports:
- "5487:5487" # Web interface
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
@@ -91,6 +92,7 @@ services:
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
# Optional: Set custom UID/GID for file permissions
# - PALMR_UID=1000
# - PALMR_GID=1000
@@ -121,9 +123,12 @@ Configure Palmr. behavior through environment variables:
| ---------------- | ------- | ------------------------------------------------------- |
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
| `ENCRYPTION_KEY` | - | **Required**: Minimum 32 characters for file encryption |
| `SECURE_SITE` | `false` | Enable secure cookies for HTTPS/reverse proxy setups |
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production. This key encrypts your files - losing it makes files permanently inaccessible.
> **🔗 Reverse Proxy**: If deploying behind a reverse proxy (Traefik, Nginx, etc.), set `SECURE_SITE=true` and review our [Reverse Proxy Configuration](/docs/3.0-beta/reverse-proxy-configuration) guide for proper setup.
### Generate Secure Encryption Keys
Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cryptographically secure keys:

View File

@@ -0,0 +1,199 @@
---
title: Reverse Proxy Configuration
icon: "Shield"
---
When deploying **Palmr.** behind a reverse proxy (like Traefik, Nginx, or Cloudflare), you need to configure secure cookie settings to ensure proper authentication. This guide covers the `SECURE_SITE` environment variable and related proxy configurations.
## Overview
Reverse proxies terminate SSL/TLS connections and forward requests to Palmr., which can cause authentication issues if cookies aren't configured properly for HTTPS environments. The `SECURE_SITE` environment variable controls cookie security settings to handle these scenarios.
## The SECURE_SITE Environment Variable
The `SECURE_SITE` variable configures how Palmr. handles authentication cookies based on your deployment environment:
### Configuration Options
| Value | Cookie Settings | Use Case |
| ------- | ------------------------------------- | ----------------------------------- |
| `true` | `secure: true`, `sameSite: "lax"` | HTTPS/Production with reverse proxy |
| `false` | `secure: false`, `sameSite: "strict"` | HTTP/Development (default) |
### When to Use SECURE_SITE=true
Set `SECURE_SITE=true` in the following scenarios:
- ✅ **Reverse Proxy with HTTPS**: Traefik, Nginx, HAProxy with SSL termination
- ✅ **Cloud Providers**: Cloudflare, AWS ALB, Azure Application Gateway
- ✅ **CDN with HTTPS**: Any CDN that terminates SSL
- ✅ **Production Deployments**: When users access via HTTPS
### When to Use SECURE_SITE=false
Keep `SECURE_SITE=false` (default) for:
- ✅ **Local Development**: Running on `http://localhost`
- ✅ **Direct HTTP Access**: No reverse proxy involved
- ✅ **Testing Environments**: When using HTTP
- ✅ **HTTP Reverse Proxy**: Nginx, Apache, etc. without SSL termination
---
## HTTP Reverse Proxy Setup
**Docker Compose for HTTP Nginx:**
```yaml
environment:
- SECURE_SITE=false # HTTP = false
```
> **⚠️ HTTP Security**: Remember that HTTP transmits data in plain text. Consider using HTTPS in production environments.
---
## Troubleshooting Authentication Issues
### Common Symptoms
If you experience authentication issues behind a reverse proxy:
- ❌ Login appears successful but redirects to login page
- ❌ "No Authorization was found in request.cookies" errors
- ❌ API requests return 401 Unauthorized
- ❌ User registration fails silently
### Diagnostic Steps
1. **Check Browser Developer Tools**:
- Look for cookies in Application/Storage tab
- Verify cookie has `Secure` flag when using HTTPS
- Check if `SameSite` attribute is appropriate
2. **Verify Environment Variables**:
```bash
docker exec -it palmr env | grep SECURE_SITE
```
3. **Test Cookie Settings**:
- With `SECURE_SITE=false`: Should work on HTTP
- With `SECURE_SITE=true`: Should work on HTTPS
### Common Fixes
**Problem**: Authentication fails with reverse proxy
**Solution**: Set `SECURE_SITE=true` and ensure proper headers:
```yaml
environment:
- SECURE_SITE=true
```
**Problem**: Mixed content errors
**Solution**: Ensure proxy passes correct headers:
```yaml
# Traefik
- "traefik.http.middlewares.palmr-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
# Nginx
proxy_set_header X-Forwarded-Proto $scheme;
```
**Problem**: Authentication fails with HTTP reverse proxy
**Solution**: Use `SECURE_SITE=false` and ensure proper cookie headers:
```yaml
environment:
- SECURE_SITE=false # For HTTP proxy
```
```nginx
# Nginx - Add these headers for cookie handling
proxy_set_header Cookie $http_cookie;
proxy_pass_header Set-Cookie;
```
**Problem**: SQLite "readonly database" error with bind mounts
**Solution**: Configure proper UID/GID permissions:
```yaml
environment:
- PALMR_UID=1000 # Your host UID (check with: id)
- PALMR_GID=1000 # Your host GID
- ENCRYPTION_KEY=your-key-here
```
> **💡 Note**: Check your host UID/GID with `id` command and use those values. See [UID/GID Configuration](/docs/3.0-beta/uid-gid-configuration) for detailed setup.
---
## Security Considerations
> **⚠️ Important**: Always use HTTPS in production environments. The `SECURE_SITE=true` setting ensures cookies are only sent over encrypted connections.
---
## Advanced Configuration
### Multiple Domains
If serving Palmr. on multiple domains, ensure consistent cookie settings:
```yaml
environment:
- SECURE_SITE=true # Use for all HTTPS domains
```
### Development vs Production
Use environment-specific configurations:
**Development (HTTP):**
```yaml
environment:
- SECURE_SITE=false
```
**Production (HTTPS):**
```yaml
environment:
- SECURE_SITE=true
```
### Health Checks
Add health checks to ensure proper proxy configuration:
```yaml
services:
palmr:
# ... other config
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5487/api/health"]
interval: 30s
timeout: 10s
retries: 3
```
---
## Need Help?
If you're still experiencing issues after following this guide:
1. **Check the Logs**: `docker logs palmr`
2. **Verify Headers**: Use browser dev tools or `curl -I`
3. **Test Direct Access**: Try accessing Palmr. directly (bypassing proxy)
4. **Open an Issue**: [Report bugs on GitHub](https://github.com/kyantech/Palmr/issues)
> **💡 Pro Tip**: When reporting issues, include your reverse proxy configuration and any relevant error messages from both Palmr. and your proxy logs.

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

View File

@@ -6,40 +6,24 @@
*/
export const timeoutConfig = {
// Connection timeouts
connection: {
// How long to wait for initial connection (0 = disabled)
timeout: 0,
// Keep-alive timeout for long-running uploads/downloads
// 20 hours should be enough for most large file operations
keepAlive: 20 * 60 * 60 * 1000, // 20 hours in milliseconds
},
// Request timeouts
request: {
// Global request timeout (0 = disabled, let requests run indefinitely)
timeout: 0,
// Body parsing timeout for large files
bodyTimeout: 0, // Disabled for large files
},
// File operation timeouts
file: {
// Maximum time to wait for file upload (0 = no limit)
uploadTimeout: 0,
// Maximum time to wait for file download (0 = no limit)
downloadTimeout: 0,
// Streaming chunk timeout (time between chunks)
streamTimeout: 30 * 1000, // 30 seconds between chunks
},
// Token expiration (for filesystem storage)
token: {
// How long upload/download tokens remain valid
expiration: 60 * 60 * 1000, // 1 hour in milliseconds
},
};
@@ -52,7 +36,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
const fileSizeGB = fileSizeBytes / (1024 * 1024 * 1024);
if (fileSizeGB > 100) {
// For files larger than 100GB, extend token expiration
return {
...timeoutConfig,
token: {
@@ -62,7 +45,6 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
}
if (fileSizeGB > 10) {
// For files larger than 10GB, extend token expiration
return {
...timeoutConfig,
token: {
@@ -79,15 +61,12 @@ export function getTimeoutForFileSize(fileSizeBytes: number) {
* You can set these in your .env file to override defaults
*/
export const envTimeoutOverrides = {
// Override connection keep-alive if set in environment
keepAliveTimeout: process.env.KEEP_ALIVE_TIMEOUT
? parseInt(process.env.KEEP_ALIVE_TIMEOUT)
: timeoutConfig.connection.keepAlive,
// Override request timeout if set in environment
requestTimeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT) : timeoutConfig.request.timeout,
// Override token expiration if set in environment
tokenExpiration: process.env.TOKEN_EXPIRATION
? parseInt(process.env.TOKEN_EXPIRATION)
: timeoutConfig.token.expiration,

View File

@@ -11,6 +11,7 @@ const envSchema = z.object({
S3_REGION: z.string().optional(),
S3_BUCKET_NAME: z.string().optional(),
S3_FORCE_PATH_STYLE: z.union([z.literal("true"), z.literal("false")]).default("false"),
SECURE_SITE: z.union([z.literal("true"), z.literal("false")]).default("false"),
DATABASE_URL: z.string().optional().default("file:/app/server/prisma/palmr.db"),
});

View File

@@ -4,7 +4,21 @@ import { FastifyReply, FastifyRequest } from "fastify";
import fs from "fs";
import path from "path";
const uploadsDir = path.join(process.cwd(), "uploads/logo");
const isDocker = (() => {
try {
require("fs").statSync("/.dockerenv");
return true;
} catch {
try {
return require("fs").readFileSync("/proc/self/cgroup", "utf8").includes("docker");
} catch {
return false;
}
}
})();
const baseDir = isDocker ? "/app/server" : process.cwd();
const uploadsDir = path.join(baseDir, "uploads/logo");
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}

View File

@@ -1,3 +1,4 @@
import { env } from "../../env";
import { LoginSchema, RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
import { AuthService } from "./service";
import { FastifyReply, FastifyRequest } from "fastify";
@@ -17,8 +18,8 @@ export class AuthController {
reply.setCookie("token", token, {
httpOnly: true,
path: "/",
secure: false,
sameSite: "strict",
secure: env.SECURE_SITE === "true" ? true : false,
sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
});
return reply.send({ user });

View File

@@ -9,7 +9,7 @@ export const createPasswordSchema = async () => {
};
export const LoginSchema = z.object({
email: z.string().email("Invalid email").describe("User email"),
emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
password: z.string().min(6, "Password must be at least 6 characters").describe("User password"),
});
export type LoginInput = z.infer<typeof LoginSchema>;

View File

@@ -17,7 +17,7 @@ export async function authRoutes(app: FastifyInstance) {
const passwordSchema = await createPasswordSchema();
const loginSchema = z.object({
email: z.string().email("Invalid email").describe("User email"),
emailOrUsername: z.string().min(1, "Email or username is required").describe("User email or username"),
password: passwordSchema,
});

View File

@@ -13,7 +13,7 @@ export class AuthService {
private emailService = new EmailService();
async login(data: LoginInput) {
const user = await this.userRepository.findUserByEmail(data.email);
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
if (!user) {
throw new Error("Invalid credentials");
}

View File

@@ -98,43 +98,82 @@ export class FilesystemController {
} catch (error) {
try {
await fs.promises.unlink(tempPath);
} catch (error) {
console.error("Error deleting temp file:", error);
} catch (cleanupError) {
console.error("Error deleting temp file:", cleanupError);
}
throw error;
}
}
private async uploadSmallFile(request: FastifyRequest, provider: FilesystemStorageProvider, objectName: string) {
const stream = request.body as any;
const chunks: Buffer[] = [];
const body = request.body as any;
return new Promise<void>((resolve, reject) => {
stream.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
if (Buffer.isBuffer(body)) {
if (body.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, body);
return;
}
stream.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
if (typeof body === "string") {
const buffer = Buffer.from(body, "utf8");
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
return;
}
if (buffer.length === 0) {
throw new Error("No file data received");
if (typeof body === "object" && body !== null && !body.on) {
const buffer = Buffer.from(JSON.stringify(body), "utf8");
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
return;
}
if (body && typeof body.on === "function") {
const chunks: Buffer[] = [];
return new Promise<void>((resolve, reject) => {
body.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
body.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
resolve();
} catch (error) {
console.error("Error uploading small file:", error);
reject(error);
}
});
await provider.uploadFile(objectName, buffer);
resolve();
} catch (error) {
console.error("Error uploading small file:", error);
body.on("error", (error: Error) => {
console.error("Error reading upload stream:", error);
reject(error);
}
});
});
}
stream.on("error", (error: Error) => {
console.error("Error reading upload stream:", error);
reject(error);
});
});
try {
const buffer = Buffer.from(body);
if (buffer.length === 0) {
throw new Error("No file data received");
}
await provider.uploadFile(objectName, buffer);
} catch (error) {
throw new Error(`Unsupported request body type: ${typeof body}. Expected stream, buffer, string, or object.`);
}
}
async download(request: FastifyRequest, reply: FastifyReply) {

View File

@@ -9,6 +9,10 @@ export async function filesystemRoutes(app: FastifyInstance) {
return payload;
});
app.addContentTypeParser("application/json", async (request: FastifyRequest, payload: any) => {
return payload;
});
app.put(
"/filesystem/upload/:token",
{

View File

@@ -6,7 +6,6 @@ import { z } from "zod";
export async function oidcRoutes(fastify: FastifyInstance) {
const oidcController = new OIDCController();
// Get OIDC configuration
fastify.get(
"/config",
{
@@ -27,7 +26,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
oidcController.getConfig.bind(oidcController)
);
// Initiate OIDC authorization
fastify.get(
"/authorize",
{
@@ -54,7 +52,6 @@ export async function oidcRoutes(fastify: FastifyInstance) {
oidcController.authorize.bind(oidcController)
);
// Handle OIDC callback
fastify.get(
"/callback",
{

View File

@@ -26,7 +26,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
}
};
// Create reverse share (authenticated)
app.post(
"/reverse-shares",
{
@@ -50,7 +49,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.createReverseShare.bind(reverseShareController)
);
// List user's reverse shares (authenticated)
app.get(
"/reverse-shares",
{
@@ -72,7 +70,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.listUserReverseShares.bind(reverseShareController)
);
// Get reverse share by ID (authenticated)
app.get(
"/reverse-shares/:id",
{
@@ -98,7 +95,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getReverseShare.bind(reverseShareController)
);
// Update reverse share (authenticated)
app.put(
"/reverse-shares",
{
@@ -123,7 +119,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.updateReverseShare.bind(reverseShareController)
);
// Update reverse share password (authenticated)
app.put(
"/reverse-shares/:id/password",
{
@@ -151,7 +146,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.updatePassword.bind(reverseShareController)
);
// Delete reverse share (authenticated)
app.delete(
"/reverse-shares/:id",
{
@@ -177,7 +171,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.deleteReverseShare.bind(reverseShareController)
);
// Get reverse share for upload (public)
app.get(
"/reverse-shares/:id/upload",
{
@@ -207,7 +200,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getReverseShareForUpload.bind(reverseShareController)
);
// Get reverse share for upload by alias (public)
app.get(
"/reverse-shares/alias/:alias/upload",
{
@@ -237,7 +229,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getReverseShareForUploadByAlias.bind(reverseShareController)
);
// Get presigned URL for file upload (public)
app.post(
"/reverse-shares/:id/presigned-url",
{
@@ -269,7 +260,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getPresignedUrl.bind(reverseShareController)
);
// Get presigned URL for file upload by alias (public)
app.post(
"/reverse-shares/alias/:alias/presigned-url",
{
@@ -301,7 +291,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.getPresignedUrlByAlias.bind(reverseShareController)
);
// Register file upload completion (public)
app.post(
"/reverse-shares/:id/register-file",
{
@@ -333,7 +322,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.registerFileUpload.bind(reverseShareController)
);
// Register file upload completion by alias (public)
app.post(
"/reverse-shares/alias/:alias/register-file",
{
@@ -365,7 +353,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.registerFileUploadByAlias.bind(reverseShareController)
);
// Check password (public)
app.post(
"/reverse-shares/:id/check-password",
{
@@ -394,7 +381,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.checkPassword.bind(reverseShareController)
);
// Download file from reverse share (authenticated)
app.get(
"/reverse-shares/files/:fileId/download",
{
@@ -421,7 +407,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.downloadFile.bind(reverseShareController)
);
// Delete file from reverse share (authenticated)
app.delete(
"/reverse-shares/files/:fileId",
{
@@ -447,7 +432,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.deleteFile.bind(reverseShareController)
);
// Create or update reverse share alias (authenticated)
app.post(
"/reverse-shares/:reverseShareId/alias",
{
@@ -486,7 +470,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.createOrUpdateAlias.bind(reverseShareController)
);
// Activate reverse share (authenticated)
app.patch(
"/reverse-shares/:id/activate",
{
@@ -512,7 +495,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.activateReverseShare.bind(reverseShareController)
);
// Deactivate reverse share (authenticated)
app.patch(
"/reverse-shares/:id/deactivate",
{
@@ -538,7 +520,6 @@ export async function reverseShareRoutes(app: FastifyInstance) {
reverseShareController.deactivateReverseShare.bind(reverseShareController)
);
// Update file from reverse share (authenticated)
app.put(
"/reverse-shares/files/:fileId",
{

View File

@@ -168,7 +168,6 @@ export class ReverseShareService {
throw new Error("Unauthorized to delete this reverse share");
}
// Delete all files associated with this reverse share
for (const file of reverseShare.files) {
try {
await this.fileService.deleteObject(file.objectName);
@@ -265,7 +264,6 @@ export class ReverseShareService {
}
}
// Check file count limit
if (reverseShare.maxFiles) {
const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShareId);
if (currentFileCount >= reverseShare.maxFiles) {
@@ -273,12 +271,10 @@ export class ReverseShareService {
}
}
// Check file size limit
if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
throw new Error("File size exceeds limit");
}
// Check allowed file types
if (reverseShare.allowedFileTypes) {
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
@@ -318,7 +314,6 @@ export class ReverseShareService {
}
}
// Check file count limit
if (reverseShare.maxFiles) {
const currentFileCount = await this.reverseShareRepository.countFilesByReverseShareId(reverseShare.id);
if (currentFileCount >= reverseShare.maxFiles) {
@@ -326,12 +321,10 @@ export class ReverseShareService {
}
}
// Check file size limit
if (reverseShare.maxFileSize && BigInt(fileData.size) > reverseShare.maxFileSize) {
throw new Error("File size exceeds limit");
}
// Check allowed file types
if (reverseShare.allowedFileTypes) {
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
if (!allowedTypes.includes(fileData.extension.toLowerCase())) {
@@ -372,10 +365,8 @@ export class ReverseShareService {
throw new Error("Unauthorized to delete this file");
}
// Delete from storage
await this.fileService.deleteObject(file.objectName);
// Delete from database
const deletedFile = await this.reverseShareRepository.deleteFile(fileId);
return this.formatFileResponse(deletedFile);
}
@@ -473,7 +464,6 @@ export class ReverseShareService {
data: { name?: string; description?: string | null },
creatorId: string
) {
// Verificar se o arquivo existe e se o usuário tem permissão
const file = await this.reverseShareRepository.findFileById(fileId);
if (!file) {
throw new Error("File not found");
@@ -483,13 +473,10 @@ export class ReverseShareService {
throw new Error("Unauthorized to edit this file");
}
// Se o nome está sendo atualizado, preservar a extensão original
const updateData = { ...data };
if (data.name) {
const originalExtension = file.extension;
// Remove qualquer extensão que o usuário possa ter digitado
const nameWithoutExtension = data.name.replace(/\.[^/.]+$/, "");
// Adiciona a extensão original (garantindo que tenha o ponto)
const extensionWithDot = originalExtension.startsWith(".") ? originalExtension : `.${originalExtension}`;
updateData.name = `${nameWithoutExtension}${extensionWithDot}`;
}

View File

@@ -22,7 +22,20 @@ export class StorageController {
const diskSpace = await this.storageService.getDiskSpace(userId, isAdmin);
return reply.send(diskSpace);
} catch (error: any) {
return reply.status(500).send({ error: error.message });
console.error("Controller error in getDiskSpace:", error);
if (error.message?.includes("Unable to determine actual disk space")) {
return reply.status(503).send({
error: "Disk space detection unavailable - system configuration issue",
details: "Please check system permissions and available disk utilities",
code: "DISK_SPACE_DETECTION_FAILED",
});
}
return reply.status(500).send({
error: "Failed to retrieve disk space information",
details: error.message || "Unknown error occurred",
});
}
}

View File

@@ -1,6 +1,8 @@
import { IS_RUNNING_IN_CONTAINER } from "../../utils/container-detection";
import { ConfigService } from "../config/service";
import { PrismaClient } from "@prisma/client";
import { exec } from "child_process";
import fs from "node:fs";
import { promisify } from "util";
const execAsync = promisify(exec);
@@ -9,6 +11,126 @@ const prisma = new PrismaClient();
export class StorageService {
private configService = new ConfigService();
private _ensureNumber(value: number, fallback: number = 0): number {
if (isNaN(value) || !isFinite(value)) {
return fallback;
}
return value;
}
private _safeParseInt(value: string): number {
const parsed = parseInt(value);
return this._ensureNumber(parsed, 0);
}
private async _tryDiskSpaceCommand(command: string): Promise<{ total: number; available: number } | null> {
try {
console.log(`Trying disk space command: ${command}`);
const { stdout, stderr } = await execAsync(command);
if (stderr) {
console.warn(`Command stderr: ${stderr}`);
}
console.log(`Command stdout: ${stdout}`);
let total = 0;
let available = 0;
if (process.platform === "win32") {
const lines = stdout.trim().split("\n").slice(1);
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 3) {
const [, size, freespace] = parts;
total += this._safeParseInt(size);
available += this._safeParseInt(freespace);
}
}
} else if (process.platform === "darwin") {
const lines = stdout.trim().split("\n");
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
} else {
const lines = stdout.trim().split("\n");
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 4) {
const [, size, , avail] = parts;
if (command.includes("-B1")) {
total = this._safeParseInt(size);
available = this._safeParseInt(avail);
} else {
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
}
}
if (total > 0 && available >= 0) {
console.log(`Successfully parsed disk space: ${total} bytes total, ${available} bytes available`);
return { total, available };
} else {
console.warn(`Invalid values parsed: total=${total}, available=${available}`);
return null;
}
} catch (error) {
console.warn(`Command failed: ${command}`, error);
return null;
}
}
private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
const pathsToTry = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server", "/app", "/"]
: [".", "./uploads", process.cwd()];
for (const pathToCheck of pathsToTry) {
console.log(`Trying path: ${pathToCheck}`);
if (pathToCheck.includes("uploads")) {
try {
if (!fs.existsSync(pathToCheck)) {
fs.mkdirSync(pathToCheck, { recursive: true });
console.log(`Created directory: ${pathToCheck}`);
}
} catch (err) {
console.warn(`Could not create path ${pathToCheck}:`, err);
continue;
}
}
if (!fs.existsSync(pathToCheck)) {
console.warn(`Path does not exist: ${pathToCheck}`);
continue;
}
const commandsToTry =
process.platform === "win32"
? ["wmic logicaldisk get size,freespace,caption"]
: process.platform === "darwin"
? [`df -k "${pathToCheck}"`, `df "${pathToCheck}"`]
: [`df -B1 "${pathToCheck}"`, `df -k "${pathToCheck}"`, `df "${pathToCheck}"`];
for (const command of commandsToTry) {
const result = await this._tryDiskSpaceCommand(command);
if (result) {
console.log(`✅ Successfully got disk space for path: ${pathToCheck}`);
return result;
}
}
}
return null;
}
async getDiskSpace(
userId?: string,
isAdmin?: boolean
@@ -20,46 +142,40 @@ export class StorageService {
}> {
try {
if (isAdmin) {
const command = process.platform === "win32"
? "wmic logicaldisk get size,freespace,caption"
: process.platform === "darwin"
? "df -k ."
: "df -B1 .";
console.log(`Running in container: ${IS_RUNNING_IN_CONTAINER}`);
const { stdout } = await execAsync(command);
let total = 0;
let available = 0;
const diskInfo = await this._getDiskSpaceMultiplePaths();
if (process.platform === "win32") {
const lines = stdout.trim().split("\n").slice(1);
for (const line of lines) {
const [, size, freespace] = line.trim().split(/\s+/);
total += parseInt(size) || 0;
available += parseInt(freespace) || 0;
}
} else if (process.platform === "darwin") {
const lines = stdout.trim().split("\n");
const [, size, , avail] = lines[1].trim().split(/\s+/);
total = parseInt(size) * 1024;
available = parseInt(avail) * 1024;
} else {
const lines = stdout.trim().split("\n");
const [, size, , avail] = lines[1].trim().split(/\s+/);
total = parseInt(size);
available = parseInt(avail);
if (!diskInfo) {
console.error("❌ CRITICAL: Could not determine disk space using any method!");
console.error("This indicates a serious system issue. Please check:");
console.error("1. File system permissions");
console.error("2. Available disk utilities (df, wmic)");
console.error("3. Container/system configuration");
throw new Error("Unable to determine actual disk space - system configuration issue");
}
const { total, available } = diskInfo;
const used = total - available;
const diskSizeGB = this._ensureNumber(total / (1024 * 1024 * 1024), 0);
const diskUsedGB = this._ensureNumber(used / (1024 * 1024 * 1024), 0);
const diskAvailableGB = this._ensureNumber(available / (1024 * 1024 * 1024), 0);
console.log(
`✅ Real disk space: ${diskSizeGB.toFixed(2)}GB total, ${diskUsedGB.toFixed(2)}GB used, ${diskAvailableGB.toFixed(2)}GB available`
);
return {
diskSizeGB: Number((total / (1024 * 1024 * 1024)).toFixed(2)),
diskUsedGB: Number((used / (1024 * 1024 * 1024)).toFixed(2)),
diskAvailableGB: Number((available / (1024 * 1024 * 1024)).toFixed(2)),
uploadAllowed: true,
diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: diskAvailableGB > 0.1, // At least 100MB free
};
} else if (userId) {
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = Number(maxTotalStorage) / (1024 * 1024 * 1024);
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
const userFiles = await prisma.file.findMany({
where: { userId },
@@ -68,21 +184,24 @@ export class StorageService {
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = Number(totalUsedStorage) / (1024 * 1024 * 1024);
const availableStorageGB = maxStorageGB - usedStorageGB;
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: maxStorageGB,
diskUsedGB: usedStorageGB,
diskAvailableGB: availableStorageGB,
diskSizeGB: Number(maxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
}
throw new Error("User ID is required for non-admin users");
} catch (error) {
console.error("Error getting disk space:", error);
throw new Error("Failed to get disk space information");
console.error("Error getting disk space:", error);
throw new Error(
`Failed to get disk space information: ${error instanceof Error ? error.message : String(error)}`
);
}
}

View File

@@ -7,6 +7,7 @@ export interface IUserRepository {
findUserByEmail(email: string): Promise<User | null>;
findUserById(id: string): Promise<User | null>;
findUserByUsername(username: string): Promise<User | null>;
findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null>;
listUsers(): Promise<User[]>;
updateUser(data: UpdateUserInput & { password?: string }): Promise<User>;
deleteUser(id: string): Promise<User>;
@@ -41,6 +42,14 @@ export class PrismaUserRepository implements IUserRepository {
return prisma.user.findUnique({ where: { username } });
}
async findUserByEmailOrUsername(emailOrUsername: string): Promise<User | null> {
return prisma.user.findFirst({
where: {
OR: [{ email: emailOrUsername }, { username: emailOrUsername }],
},
});
}
async listUsers(): Promise<User[]> {
return prisma.user.findMany();
}

View File

@@ -14,20 +14,25 @@ export async function userRoutes(app: FastifyInstance) {
const usersCount = await prisma.user.count();
if (usersCount > 0) {
await request.jwtVerify();
if (!request.user.isAdmin) {
try {
await request.jwtVerify();
if (!request.user.isAdmin) {
return reply
.status(403)
.send({ error: "Access restricted to administrators" })
.description("Access restricted to administrators");
}
} catch (authErr) {
console.error(authErr);
return reply
.status(403)
.send({ error: "Access restricted to administrators" })
.description("Access restricted to administrators");
.status(401)
.send({ error: "Unauthorized: a valid token is required to access this resource." })
.description("Unauthorized: a valid token is required to access this resource.");
}
}
} catch (err) {
console.error(err);
return reply
.status(401)
.send({ error: "Unauthorized: a valid token is required to access this resource." })
.description("Unauthorized: a valid token is required to access this resource.");
return reply.status(500).send({ error: "Internal server error" }).description("Internal server error");
}
};

View File

@@ -1,5 +1,6 @@
import { env } from "../env";
import { StorageProvider } from "../types/storage";
import { IS_RUNNING_IN_CONTAINER } from "../utils/container-detection";
import * as crypto from "crypto";
import * as fsSync from "fs";
import * as fs from "fs/promises";
@@ -9,12 +10,14 @@ import { pipeline } from "stream/promises";
export class FilesystemStorageProvider implements StorageProvider {
private static instance: FilesystemStorageProvider;
private uploadsDir = path.join(process.cwd(), "uploads");
private uploadsDir: string;
private encryptionKey = env.ENCRYPTION_KEY;
private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
private constructor() {
this.uploadsDir = IS_RUNNING_IN_CONTAINER ? "/app/server/uploads" : path.join(process.cwd(), "uploads");
this.ensureUploadsDir();
setInterval(() => this.cleanExpiredTokens(), 5 * 60 * 1000);
}
@@ -214,8 +217,10 @@ export class FilesystemStorageProvider implements StorageProvider {
if (encryptedBuffer.length > 16) {
try {
return this.decryptFileBuffer(encryptedBuffer);
} catch (error) {
console.warn("Failed to decrypt with new method, trying legacy format");
} catch (error: unknown) {
if (error instanceof Error) {
console.warn("Failed to decrypt with new method, trying legacy format", error.message);
}
return this.decryptFileLegacy(encryptedBuffer);
}
}

View File

@@ -5,7 +5,6 @@ import * as readline from "readline";
const prisma = new PrismaClient();
// Função para ler entrada do usuário de forma assíncrona
function createReadlineInterface() {
return readline.createInterface({
input: process.stdin,
@@ -17,15 +16,12 @@ function question(rl: readline.Interface, query: string): Promise<string> {
return new Promise((resolve) => rl.question(query, resolve));
}
// Função para validar formato de email básico
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Função para validar senha com base nas regras do sistema
function isValidPassword(password: string): boolean {
// Minimum length baseado na configuração padrão do sistema (8 caracteres)
return password.length >= 8;
}
@@ -38,7 +34,6 @@ async function resetUserPassword() {
console.log("This script allows you to reset a user's password directly from the Docker terminal.");
console.log("⚠️ WARNING: This bypasses normal security checks. Use only when necessary!\n");
// Solicitar email do usuário
let email: string;
let user: any;
@@ -55,7 +50,6 @@ async function resetUserPassword() {
continue;
}
// Buscar usuário no banco de dados
user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
select: {
@@ -83,7 +77,6 @@ async function resetUserPassword() {
break;
}
// Mostrar informações do usuário encontrado
console.log("\n✅ User found:");
console.log(` Name: ${user.firstName} ${user.lastName}`);
console.log(` Username: ${user.username}`);
@@ -91,14 +84,12 @@ async function resetUserPassword() {
console.log(` Status: ${user.isActive ? "Active" : "Inactive"}`);
console.log(` Admin: ${user.isAdmin ? "Yes" : "No"}\n`);
// Confirmar se deseja prosseguir
const confirm = await question(rl, "Do you want to reset the password for this user? (y/n): ");
if (confirm.toLowerCase() !== "y") {
console.log("\n👋 Operation cancelled.");
return;
}
// Solicitar nova senha
let newPassword: string;
while (true) {
console.log("\n🔑 Enter new password requirements:");
@@ -126,18 +117,15 @@ async function resetUserPassword() {
break;
}
// Hash da senha usando bcrypt (mesmo método usado pelo sistema)
console.log("\n🔄 Hashing password...");
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Atualizar senha no banco de dados
console.log("💾 Updating password in database...");
await prisma.user.update({
where: { id: user.id },
data: { password: hashedPassword },
});
// Limpar tokens de reset de senha existentes para este usuário
console.log("🧹 Cleaning up existing password reset tokens...");
await prisma.passwordReset.deleteMany({
where: {
@@ -159,7 +147,6 @@ async function resetUserPassword() {
}
}
// Função para listar usuários (funcionalidade auxiliar)
async function listUsers() {
try {
console.log("\n👥 Registered Users:");
@@ -198,7 +185,6 @@ async function listUsers() {
}
}
// Main function
async function main() {
const args = process.argv.slice(2);
@@ -227,7 +213,6 @@ async function main() {
await resetUserPassword();
}
// Handle process termination
process.on("SIGINT", async () => {
console.log("\n\n👋 Goodbye!");
await prisma.$disconnect();
@@ -239,7 +224,6 @@ process.on("SIGTERM", async () => {
process.exit(0);
});
// Run the script
if (require.main === module) {
main().catch(console.error);
}

View File

@@ -11,6 +11,7 @@ import { reverseShareRoutes } from "./modules/reverse-share/routes";
import { shareRoutes } from "./modules/share/routes";
import { storageRoutes } from "./modules/storage/routes";
import { userRoutes } from "./modules/user/routes";
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import * as fs from "fs/promises";
@@ -26,21 +27,22 @@ if (typeof global.crypto === "undefined") {
}
async function ensureDirectories() {
const uploadsDir = path.join(process.cwd(), "uploads");
const tempChunksDir = path.join(process.cwd(), "temp-chunks");
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
const uploadsDir = path.join(baseDir, "uploads");
const tempChunksDir = path.join(baseDir, "temp-chunks");
try {
await fs.access(uploadsDir);
} catch {
await fs.mkdir(uploadsDir, { recursive: true });
console.log("📁 Created uploads directory");
console.log(`📁 Created uploads directory: ${uploadsDir}`);
}
try {
await fs.access(tempChunksDir);
} catch {
await fs.mkdir(tempChunksDir, { recursive: true });
console.log("📁 Created temp-chunks directory");
console.log(`📁 Created temp-chunks directory: ${tempChunksDir}`);
}
}
@@ -62,8 +64,11 @@ async function startServer() {
});
if (env.ENABLE_S3 !== "true") {
const baseDir = IS_RUNNING_IN_CONTAINER ? "/app/server" : process.cwd();
const uploadsPath = path.join(baseDir, "uploads");
await app.register(fastifyStatic, {
root: path.join(process.cwd(), "uploads"),
root: uploadsPath,
prefix: "/uploads/",
decorateReply: false,
});

View File

@@ -0,0 +1,45 @@
import * as fsSync from "fs";
/**
* Determines if the application is running inside a container environment.
* Checks common container indicators like /.dockerenv and cgroup file patterns.
*
* This function caches its result after the first call for performance.
*
* @returns {boolean} True if running in a container, false otherwise.
*/
function isRunningInContainer(): boolean {
try {
if (fsSync.existsSync("/.dockerenv")) {
return true;
}
const cgroupContent = fsSync.readFileSync("/proc/self/cgroup", "utf8");
const containerPatterns = [
"docker",
"containerd",
"lxc",
"kubepods",
"pod",
"/containers/",
"system.slice/container-",
];
for (const pattern of containerPatterns) {
if (cgroupContent.includes(pattern)) {
return true;
}
}
if (fsSync.existsSync("/.well-known/container")) {
return true;
}
} catch (e: unknown) {
if (e instanceof Error) {
console.warn("Could not perform full container detection:", e.message);
}
}
return false;
}
export const IS_RUNNING_IN_CONTAINER = isRunningInContainer();

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "مرحبا بك",
"signInToContinue": "قم بتسجيل الدخول للمتابعة",
"emailOrUsernameLabel": "البريد الإلكتروني أو اسم المستخدم",
"emailOrUsernamePlaceholder": "أدخل بريدك الإلكتروني أو اسم المستخدم",
"emailLabel": "البريد الإلكتروني",
"emailPlaceholder": "أدخل بريدك الإلكتروني",
"passwordLabel": "كلمة المرور",
@@ -730,7 +732,15 @@
"title": "استخدام التخزين",
"ariaLabel": "شريط تقدم استخدام التخزين",
"used": "المستخدمة",
"available": "المتاحة"
"available": "متاح",
"loading": "جارٍ التحميل...",
"retry": "إعادة المحاولة",
"errors": {
"title": "معلومات التخزين غير متوفرة",
"detectionFailed": "تعذر اكتشاف مساحة القرص. قد يكون هذا بسبب مشاكل في إعدادات النظام أو صلاحيات غير كافية.",
"serverError": "حدث خطأ في الخادم أثناء استرجاع معلومات التخزين. يرجى المحاولة مرة أخرى لاحقاً.",
"unknown": "حدث خطأ غير متوقع أثناء تحميل معلومات التخزين."
}
},
"theme": {
"toggle": "تبديل السمة",
@@ -748,6 +758,7 @@
"uploadProgress": "تقدم الرفع",
"upload": "رفع",
"startUploads": "بدء الرفع",
"retry": "إعادة المحاولة",
"finish": "إنهاء",
"success": "تم رفع الملف بنجاح",
"allSuccess": "{count, plural, =1 {تم رفع الملف بنجاح} other {تم رفع # ملف بنجاح}}",
@@ -844,6 +855,7 @@
"passwordLength": "يجب أن تحتوي كلمة المرور على 8 أحرف على الأقل",
"passwordsMatch": "كلمتا المرور غير متطابقتين",
"emailRequired": "البريد الإلكتروني مطلوب",
"emailOrUsernameRequired": "البريد الإلكتروني أو اسم المستخدم مطلوب",
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
@@ -1269,6 +1281,7 @@
"linkInactive": "هذا الرابط غير نشط.",
"linkExpired": "هذا الرابط منتهي الصلاحية.",
"uploadFailed": "خطأ في رفع الملف",
"retry": "إعادة المحاولة",
"fileTooLarge": "الملف كبير جداً. الحجم الأقصى: {maxSize}",
"fileTypeNotAllowed": "نوع الملف غير مسموح به. الأنواع المقبولة: {allowedTypes}",
"maxFilesExceeded": "الحد الأقصى المسموح به هو {maxFiles} ملف/ملفات",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Willkommen zu",
"signInToContinue": "Melden Sie sich an, um fortzufahren",
"emailOrUsernameLabel": "E-Mail-Adresse oder Benutzername",
"emailOrUsernamePlaceholder": "Geben Sie Ihre E-Mail-Adresse oder Benutzernamen ein",
"emailLabel": "E-Mail-Adresse",
"emailPlaceholder": "Geben Sie Ihre E-Mail-Adresse ein",
"passwordLabel": "Passwort",
@@ -730,7 +732,15 @@
"title": "Speichernutzung",
"ariaLabel": "Fortschrittsbalken der Speichernutzung",
"used": "genutzt",
"available": "verfügbar"
"available": "verfügbar",
"loading": "Wird geladen...",
"retry": "Wiederholen",
"errors": {
"title": "Speicherinformationen nicht verfügbar",
"detectionFailed": "Speicherplatz konnte nicht erkannt werden. Dies kann an Systemkonfigurationsproblemen oder unzureichenden Berechtigungen liegen.",
"serverError": "Serverfehler beim Abrufen der Speicherinformationen. Bitte versuchen Sie es später erneut.",
"unknown": "Ein unerwarteter Fehler ist beim Laden der Speicherinformationen aufgetreten."
}
},
"theme": {
"toggle": "Design umschalten",
@@ -748,6 +758,7 @@
"uploadProgress": "Upload-Fortschritt",
"upload": "Hochladen",
"startUploads": "Uploads Starten",
"retry": "Wiederholen",
"finish": "Beenden",
"success": "Datei erfolgreich hochgeladen",
"allSuccess": "{count, plural, =1 {Datei erfolgreich hochgeladen} other {# Dateien erfolgreich hochgeladen}}",
@@ -844,6 +855,7 @@
"passwordLength": "Das Passwort muss mindestens 8 Zeichen lang sein",
"passwordsMatch": "Die Passwörter stimmen nicht überein",
"emailRequired": "E-Mail ist erforderlich",
"emailOrUsernameRequired": "E-Mail oder Benutzername ist erforderlich",
"passwordRequired": "Passwort ist erforderlich",
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
@@ -1269,6 +1281,7 @@
"linkInactive": "Dieser Link ist inaktiv.",
"linkExpired": "Dieser Link ist abgelaufen.",
"uploadFailed": "Fehler beim Hochladen der Datei",
"retry": "Wiederholen",
"fileTooLarge": "Datei zu groß. Maximale Größe: {maxSize}",
"fileTypeNotAllowed": "Dateityp nicht erlaubt. Erlaubte Typen: {allowedTypes}",
"maxFilesExceeded": "Maximal {maxFiles} Dateien erlaubt",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Welcome to",
"signInToContinue": "Sign in to continue",
"emailOrUsernameLabel": "Email or Username",
"emailOrUsernamePlaceholder": "Enter your email or username",
"emailLabel": "Email Address",
"emailPlaceholder": "Enter your email",
"passwordLabel": "Password",
@@ -788,7 +790,15 @@
"title": "Storage Usage",
"ariaLabel": "Storage usage progress bar",
"used": "used",
"available": "available"
"available": "available",
"loading": "Loading...",
"retry": "Retry",
"errors": {
"title": "Storage information unavailable",
"detectionFailed": "Unable to detect disk space. This may be due to system configuration issues or insufficient permissions.",
"serverError": "Server error occurred while retrieving storage information. Please try again later.",
"unknown": "An unexpected error occurred while loading storage information."
}
},
"theme": {
"toggle": "Toggle theme",
@@ -806,6 +816,7 @@
"uploadProgress": "Upload progress",
"upload": "Upload",
"startUploads": "Start Uploads",
"retry": "Retry",
"finish": "Finish",
"success": "File uploaded successfully",
"allSuccess": "{count, plural, =1 {File uploaded successfully} other {# files uploaded successfully}}",
@@ -901,6 +912,7 @@
"passwordLength": "Password must be at least 8 characters long",
"passwordsMatch": "Passwords must match",
"emailRequired": "Email is required",
"emailOrUsernameRequired": "Email or username is required",
"passwordRequired": "Password is required",
"passwordMinLength": "Password must be at least 6 characters",
"nameRequired": "Name is required",
@@ -1269,6 +1281,7 @@
"linkInactive": "This link is inactive.",
"linkExpired": "This link has expired.",
"uploadFailed": "Error uploading file",
"retry": "Retry",
"fileTooLarge": "File too large. Maximum size: {maxSize}",
"fileTypeNotAllowed": "File type not allowed. Accepted types: {allowedTypes}",
"maxFilesExceeded": "Maximum of {maxFiles} files allowed",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Bienvenido a",
"signInToContinue": "Inicia sesión para continuar",
"emailOrUsernameLabel": "Correo electrónico o nombre de usuario",
"emailOrUsernamePlaceholder": "Introduce tu correo electrónico o nombre de usuario",
"emailLabel": "Dirección de correo electrónico",
"emailPlaceholder": "Introduce tu correo electrónico",
"passwordLabel": "Contraseña",
@@ -730,7 +732,15 @@
"title": "Uso de almacenamiento",
"ariaLabel": "Barra de progreso del uso de almacenamiento",
"used": "usados",
"available": "disponibles"
"available": "disponible",
"loading": "Cargando...",
"retry": "Reintentar",
"errors": {
"title": "Información de almacenamiento no disponible",
"detectionFailed": "No se pudo detectar el espacio en disco. Esto puede deberse a problemas de configuración del sistema o permisos insuficientes.",
"serverError": "Ocurrió un error del servidor al recuperar la información de almacenamiento. Por favor, inténtelo de nuevo más tarde.",
"unknown": "Ocurrió un error inesperado al cargar la información de almacenamiento."
}
},
"theme": {
"toggle": "Cambiar tema",
@@ -748,6 +758,7 @@
"uploadProgress": "Progreso de la subida",
"upload": "Subir",
"startUploads": "Iniciar Subidas",
"retry": "Reintentar",
"finish": "Finalizar",
"success": "Archivo subido exitosamente",
"allSuccess": "{count, plural, =1 {Archivo subido exitosamente} other {# archivos subidos exitosamente}}",
@@ -844,6 +855,7 @@
"passwordLength": "La contraseña debe tener al menos 8 caracteres",
"passwordsMatch": "Las contraseñas no coinciden",
"emailRequired": "Se requiere el correo electrónico",
"emailOrUsernameRequired": "Se requiere el correo electrónico o nombre de usuario",
"passwordRequired": "Se requiere la contraseña",
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
@@ -1269,6 +1281,7 @@
"linkInactive": "Este enlace está inactivo.",
"linkExpired": "Este enlace ha expirado.",
"uploadFailed": "Error al subir archivo",
"retry": "Reintentar",
"fileTooLarge": "Archivo demasiado grande. Tamaño máximo: {maxSize}",
"fileTypeNotAllowed": "Tipo de archivo no permitido. Tipos aceptados: {allowedTypes}",
"maxFilesExceeded": "Máximo de {maxFiles} archivos permitidos",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Bienvenue à",
"signInToContinue": "Connectez-vous pour continuer",
"emailOrUsernameLabel": "Email ou Nom d'utilisateur",
"emailOrUsernamePlaceholder": "Entrez votre email ou nom d'utilisateur",
"emailLabel": "Adresse e-mail",
"emailPlaceholder": "Entrez votre e-mail",
"passwordLabel": "Mot de passe",
@@ -730,7 +732,15 @@
"title": "Utilisation du Stockage",
"ariaLabel": "Barre de progression de l'utilisation du stockage",
"used": "utilisé",
"available": "disponible"
"available": "disponible",
"loading": "Chargement...",
"retry": "Réessayer",
"errors": {
"title": "Informations de stockage non disponibles",
"detectionFailed": "Impossible de détecter l'espace disque. Cela peut être dû à des problèmes de configuration système ou à des permissions insuffisantes.",
"serverError": "Une erreur serveur s'est produite lors de la récupération des informations de stockage. Veuillez réessayer plus tard.",
"unknown": "Une erreur inattendue s'est produite lors du chargement des informations de stockage."
}
},
"theme": {
"toggle": "Changer le thème",
@@ -748,6 +758,7 @@
"uploadProgress": "Progression du téléchargement",
"upload": "Télécharger",
"startUploads": "Commencer les Téléchargements",
"retry": "Réessayer",
"finish": "Terminer",
"success": "Fichier téléchargé avec succès",
"allSuccess": "{count, plural, =1 {Fichier téléchargé avec succès} other {# fichiers téléchargés avec succès}}",
@@ -844,6 +855,7 @@
"passwordLength": "Le mot de passe doit contenir au moins 8 caractères",
"passwordsMatch": "Les mots de passe ne correspondent pas",
"emailRequired": "L'email est requis",
"emailOrUsernameRequired": "L'email ou le nom d'utilisateur est requis",
"passwordRequired": "Le mot de passe est requis",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
@@ -1269,6 +1281,7 @@
"linkInactive": "Ce lien est inactif.",
"linkExpired": "Ce lien a expiré.",
"uploadFailed": "Erreur lors de l'envoi du fichier",
"retry": "Réessayer",
"fileTooLarge": "Fichier trop volumineux. Taille maximale : {maxSize}",
"fileTypeNotAllowed": "Type de fichier non autorisé. Types acceptés : {allowedTypes}",
"maxFilesExceeded": "Maximum de {maxFiles} fichiers autorisés",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "स्वागत है में",
"signInToContinue": "जारी रखने के लिए साइन इन करें",
"emailOrUsernameLabel": "ईमेल या उपयोगकर्ता नाम",
"emailOrUsernamePlaceholder": "अपना ईमेल या उपयोगकर्ता नाम दर्ज करें",
"emailLabel": "ईमेल पता",
"emailPlaceholder": "अपना ईमेल दर्ज करें",
"passwordLabel": "पासवर्ड",
@@ -730,7 +732,15 @@
"title": "स्टोरेज उपयोग",
"ariaLabel": "स्टोरेज उपयोग प्रगति पट्टी",
"used": "उपयोग किया गया",
"available": "उपलब्ध"
"available": "उपलब्ध",
"loading": "लोड हो रहा है...",
"retry": "पुनः प्रयास करें",
"errors": {
"title": "स्टोरेज जानकारी अनुपलब्ध",
"detectionFailed": "डिस्क स्पेस का पता लगाने में असमर्थ। यह सिस्टम कॉन्फ़िगरेशन समस्याओं या अपर्याप्त अनुमतियों के कारण हो सकता है।",
"serverError": "स्टोरेज जानकारी प्राप्त करते समय सर्वर त्रुटि हुई। कृपया बाद में पुनः प्रयास करें।",
"unknown": "स्टोरेज जानकारी लोड करते समय एक अनपेक्षित त्रुटि हुई।"
}
},
"theme": {
"toggle": "थीम टॉगल करें",
@@ -748,6 +758,7 @@
"uploadProgress": "अपलोड प्रगति",
"upload": "अपलोड",
"startUploads": "अपलोड शुरू करें",
"retry": "पुनः प्रयास करें",
"finish": "समाप्त",
"success": "फ़ाइल सफलतापूर्वक अपलोड की गई",
"allSuccess": "{count, plural, =1 {फ़ाइल सफलतापूर्वक अपलोड की गई} other {# फ़ाइलें सफलतापूर्वक अपलोड की गईं}}",
@@ -844,6 +855,7 @@
"passwordLength": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए",
"passwordsMatch": "पासवर्ड मेल नहीं खाते",
"emailRequired": "ईमेल आवश्यक है",
"emailOrUsernameRequired": "ईमेल या उपयोगकर्ता नाम आवश्यक है",
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
@@ -1269,6 +1281,7 @@
"linkInactive": "यह लिंक निष्क्रिय है।",
"linkExpired": "यह लिंक समाप्त हो गया है।",
"uploadFailed": "फ़ाइल अपलोड करने में त्रुटि",
"retry": "पुनः प्रयास करें",
"fileTooLarge": "फ़ाइल बहुत बड़ी है। अधिकतम आकार: {maxSize}",
"fileTypeNotAllowed": "फ़ाइल प्रकार अनुमत नहीं है। स्वीकृत प्रकार: {allowedTypes}",
"maxFilesExceeded": "अधिकतम {maxFiles} फ़ाइलें अनुमत हैं",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Benvenuto in",
"signInToContinue": "Accedi per continuare",
"emailOrUsernameLabel": "Email o Nome utente",
"emailOrUsernamePlaceholder": "Inserisci la tua email o nome utente",
"emailLabel": "Indirizzo Email",
"emailPlaceholder": "Inserisci la tua email",
"passwordLabel": "Parola d'accesso",
@@ -730,7 +732,15 @@
"title": "Utilizzo Archiviazione",
"ariaLabel": "Barra di progresso utilizzo archiviazione",
"used": "utilizzato",
"available": "disponibile"
"available": "disponibile",
"loading": "Caricamento...",
"retry": "Riprova",
"errors": {
"title": "Informazioni di archiviazione non disponibili",
"detectionFailed": "Impossibile rilevare lo spazio su disco. Ciò potrebbe essere dovuto a problemi di configurazione del sistema o permessi insufficienti.",
"serverError": "Si è verificato un errore del server durante il recupero delle informazioni di archiviazione. Riprova più tardi.",
"unknown": "Si è verificato un errore imprevisto durante il caricamento delle informazioni di archiviazione."
}
},
"theme": {
"toggle": "Cambia tema",
@@ -748,6 +758,7 @@
"uploadProgress": "Progresso caricamento",
"upload": "Carica",
"startUploads": "Inizia Caricamenti",
"retry": "Riprova",
"finish": "Termina",
"success": "File caricato con successo",
"allSuccess": "{count, plural, =1 {File caricato con successo} other {# file caricati con successo}}",
@@ -843,6 +854,7 @@
"passwordLength": "La parola d'accesso deve essere di almeno 8 caratteri",
"passwordsMatch": "Le parole d'accesso devono corrispondere",
"emailRequired": "L'indirizzo email è obbligatorio",
"emailOrUsernameRequired": "L'indirizzo email o il nome utente è obbligatorio",
"passwordRequired": "La parola d'accesso è obbligatoria",
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
"nameRequired": "Il nome è obbligatorio",
@@ -1269,6 +1281,7 @@
"linkInactive": "Questo link è inattivo.",
"linkExpired": "Questo link è scaduto.",
"uploadFailed": "Errore durante l'invio del file",
"retry": "Riprova",
"fileTooLarge": "File troppo grande. Dimensione massima: {maxSize}",
"fileTypeNotAllowed": "Tipo di file non consentito. Tipi accettati: {allowedTypes}",
"maxFilesExceeded": "Massimo {maxFiles} file consentiti",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "ようこそへ",
"signInToContinue": "続行するにはサインインしてください",
"emailOrUsernameLabel": "メールアドレスまたはユーザー名",
"emailOrUsernamePlaceholder": "メールアドレスまたはユーザー名を入力してください",
"emailLabel": "メールアドレス",
"emailPlaceholder": "メールアドレスを入力してください",
"passwordLabel": "パスワード",
@@ -730,7 +732,15 @@
"title": "ストレージ使用量",
"ariaLabel": "ストレージ使用状況のプログレスバー",
"used": "使用済み",
"available": "利用可能"
"available": "利用可能",
"loading": "読み込み中...",
"retry": "再試行",
"errors": {
"title": "ストレージ情報が利用できません",
"detectionFailed": "ディスク容量を検出できません。システム設定の問題または権限が不足している可能性があります。",
"serverError": "ストレージ情報の取得中にサーバーエラーが発生しました。後でもう一度お試しください。",
"unknown": "ストレージ情報の読み込み中に予期せぬエラーが発生しました。"
}
},
"theme": {
"toggle": "テーマを切り替える",
@@ -761,6 +771,7 @@
},
"multipleTitle": "複数ファイルをアップロード",
"startUploads": "アップロードを開始",
"retry": "再試行",
"allSuccess": "{count, plural, =1 {ファイルがアップロードされました} other {#個のファイルがアップロードされました}}",
"partialSuccess": "{success}個のファイルがアップロードされ、{error}個が失敗しました",
"dragAndDrop": "またはここにファイルをドラッグ&ドロップ"
@@ -844,6 +855,7 @@
"passwordLength": "パスワードは最低8文字必要です",
"passwordsMatch": "パスワードが一致しません",
"emailRequired": "メールアドレスは必須です",
"emailOrUsernameRequired": "メールアドレスまたはユーザー名は必須です",
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
@@ -1269,6 +1281,7 @@
"linkInactive": "このリンクは無効です。",
"linkExpired": "このリンクは期限切れです。",
"uploadFailed": "ファイルのアップロードに失敗しました",
"retry": "再試行",
"fileTooLarge": "ファイルが大きすぎます。最大サイズ: {maxSize}",
"fileTypeNotAllowed": "このファイル形式は許可されていません。許可される形式: {allowedTypes}",
"maxFilesExceeded": "最大 {maxFiles} ファイルまで許可されています",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "에 오신 것을 환영합니다",
"signInToContinue": "계속하려면 로그인하세요",
"emailOrUsernameLabel": "이메일 또는 사용자 이름",
"emailOrUsernamePlaceholder": "이메일 또는 사용자 이름을 입력하세요",
"emailLabel": "이메일 주소",
"emailPlaceholder": "이메일을 입력하세요",
"passwordLabel": "비밀번호",
@@ -730,7 +732,15 @@
"title": "스토리지 사용량",
"ariaLabel": "스토리지 사용량 진행 바",
"used": "사용됨",
"available": "사용 가능"
"available": "사용 가능",
"loading": "로딩 중...",
"retry": "다시 시도",
"errors": {
"title": "스토리지 정보를 사용할 수 없음",
"detectionFailed": "디스크 공간을 감지할 수 없습니다. 시스템 구성 문제 또는 권한이 부족한 것이 원인일 수 있습니다.",
"serverError": "스토리지 정보를 검색하는 중에 서버 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"unknown": "스토리지 정보를 로드하는 중에 예기치 않은 오류가 발생했습니다."
}
},
"theme": {
"toggle": "테마 전환",
@@ -748,6 +758,7 @@
"uploadProgress": "업로드 진행률",
"upload": "업로드",
"startUploads": "업로드 시작",
"retry": "다시 시도",
"finish": "완료",
"success": "파일이 성공적으로 업로드되었습니다",
"allSuccess": "{count, plural, =1 {파일이 성공적으로 업로드되었습니다} other {# 개 파일이 성공적으로 업로드되었습니다}}",
@@ -844,6 +855,7 @@
"passwordLength": "비밀번호는 최소 8자 이상이어야 합니다",
"passwordsMatch": "비밀번호가 일치하지 않습니다",
"emailRequired": "이메일은 필수입니다",
"emailOrUsernameRequired": "이메일 또는 사용자 이름은 필수입니다",
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
@@ -1269,6 +1281,7 @@
"linkInactive": "이 링크는 비활성 상태입니다.",
"linkExpired": "이 링크는 만료되었습니다.",
"uploadFailed": "파일 업로드 오류",
"retry": "다시 시도",
"fileTooLarge": "파일이 너무 큽니다. 최대 크기: {maxSize}",
"fileTypeNotAllowed": "허용되지 않는 파일 유형입니다. 허용된 유형: {allowedTypes}",
"maxFilesExceeded": "최대 {maxFiles}개의 파일만 허용됩니다",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Welkom bij",
"signInToContinue": "Log in om door te gaan",
"emailOrUsernameLabel": "E-mail of Gebruikersnaam",
"emailOrUsernamePlaceholder": "Voer je e-mail of gebruikersnaam in",
"emailLabel": "E-mailadres",
"emailPlaceholder": "Voer je e-mail in",
"passwordLabel": "Wachtwoord",
@@ -730,7 +732,15 @@
"title": "Opslaggebruik",
"ariaLabel": "Opslaggebruik voortgangsbalk",
"used": "gebruikt",
"available": "beschikbaar"
"available": "beschikbaar",
"loading": "Laden...",
"retry": "Opnieuw proberen",
"errors": {
"title": "Opslaginformatie niet beschikbaar",
"detectionFailed": "Kan schijfruimte niet detecteren. Dit kan komen door systeemconfiguratieproblemen of onvoldoende rechten.",
"serverError": "Er is een serverfout opgetreden bij het ophalen van opslaginformatie. Probeer het later opnieuw.",
"unknown": "Er is een onverwachte fout opgetreden bij het laden van opslaginformatie."
}
},
"theme": {
"toggle": "Thema wisselen",
@@ -748,6 +758,7 @@
"uploadProgress": "Upload voortgang",
"upload": "Uploaden",
"startUploads": "Uploads Starten",
"retry": "Opnieuw Proberen",
"finish": "Voltooien",
"success": "Bestand succesvol geüpload",
"allSuccess": "{count, plural, =1 {Bestand succesvol geüpload} other {# bestanden succesvol geüpload}}",
@@ -843,6 +854,7 @@
"passwordLength": "Wachtwoord moet minimaal 8 tekens zijn",
"passwordsMatch": "Wachtwoorden moeten overeenkomen",
"emailRequired": "E-mail is verplicht",
"emailOrUsernameRequired": "E-mail of gebruikersnaam is verplicht",
"passwordRequired": "Wachtwoord is verplicht",
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
"nameRequired": "Naam is verplicht",
@@ -1269,6 +1281,7 @@
"linkInactive": "Deze link is inactief.",
"linkExpired": "Deze link is verlopen.",
"uploadFailed": "Fout bij uploaden bestand",
"retry": "Opnieuw Proberen",
"fileTooLarge": "Bestand te groot. Maximum grootte: {maxSize}",
"fileTypeNotAllowed": "Bestandstype niet toegestaan. Toegestane types: {allowedTypes}",
"maxFilesExceeded": "Maximum van {maxFiles} bestanden toegestaan",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Witaj w",
"signInToContinue": "Zaloguj się, aby kontynuować",
"emailOrUsernameLabel": "E-mail lub nazwa użytkownika",
"emailOrUsernamePlaceholder": "Wprowadź swój e-mail lub nazwę użytkownika",
"emailLabel": "Adres e-mail",
"emailPlaceholder": "Wprowadź swój adres e-mail",
"passwordLabel": "Hasło",
@@ -788,7 +790,15 @@
"title": "Użycie pamięci",
"ariaLabel": "Pasek postępu użycia pamięci",
"used": "użyte",
"available": "dostępne"
"available": "dostępne",
"loading": "Ładowanie...",
"retry": "Spróbuj ponownie",
"errors": {
"title": "Informacje o pamięci niedostępne",
"detectionFailed": "Nie można wykryć miejsca na dysku. Może to być spowodowane problemami z konfiguracją systemu lub niewystarczającymi uprawnieniami.",
"serverError": "Wystąpił błąd serwera podczas pobierania informacji o pamięci. Spróbuj ponownie później.",
"unknown": "Wystąpił nieoczekiwany błąd podczas ładowania informacji o pamięci."
}
},
"theme": {
"toggle": "Przełącz motyw",
@@ -806,6 +816,7 @@
"uploadProgress": "Postęp przesyłania",
"upload": "Prześlij",
"startUploads": "Rozpocznij przesyłanie",
"retry": "Spróbuj Ponownie",
"finish": "Zakończ",
"success": "Plik przesłany pomyślnie",
"allSuccess": "{count, plural, =1 {Plik przesłany pomyślnie} other {# plików przesłanych pomyślnie}}",
@@ -901,6 +912,7 @@
"passwordLength": "Hasło musi mieć co najmniej 8 znaków",
"passwordsMatch": "Hasła muszą być zgodne",
"emailRequired": "E-mail jest wymagany",
"emailOrUsernameRequired": "E-mail lub nazwa użytkownika jest wymagana",
"passwordRequired": "Hasło jest wymagane",
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
"nameRequired": "Nazwa jest wymagana",
@@ -1269,6 +1281,7 @@
"linkInactive": "Ten link jest nieaktywny.",
"linkExpired": "Ten link wygasł.",
"uploadFailed": "Błąd przesyłania pliku",
"retry": "Spróbuj Ponownie",
"fileTooLarge": "Plik za duży. Maksymalny rozmiar: {maxSize}",
"fileTypeNotAllowed": "Typ pliku niedozwolony. Akceptowane typy: {allowedTypes}",
"maxFilesExceeded": "Dozwolono maksymalnie {maxFiles} plików",

View File

@@ -18,17 +18,17 @@
"click": "Clique para"
},
"createShare": {
"title": "Criar Compartilhamento",
"nameLabel": "Nome do Compartilhamento",
"title": "Criar compartilhamento",
"nameLabel": "Nome do compartilhamento",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)",
"expirationLabel": "Data de Expiração",
"expirationLabel": "Data de expiração",
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações",
"maxViewsLabel": "Máximo de visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por Senha",
"passwordLabel": "Senha",
"create": "Criar Compartilhamento",
"create": "Criar compartilhamento",
"success": "Compartilhamento criado com sucesso",
"error": "Falha ao criar compartilhamento"
},
@@ -44,7 +44,7 @@
},
"emptyState": {
"noFiles": "Nenhum arquivo enviado ainda",
"uploadFile": "Enviar Arquivo"
"uploadFile": "Enviar arquivo"
},
"errors": {
"invalidCredentials": "E-mail ou senha inválidos",
@@ -53,13 +53,13 @@
"unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente"
},
"fileActions": {
"editFile": "Editar Arquivo",
"editFile": "Editar arquivo",
"nameLabel": "Nome",
"namePlaceholder": "Digite o novo nome",
"extension": "Extensão",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite a descrição do arquivo",
"deleteFile": "Excluir Arquivo",
"deleteFile": "Excluir arquivo",
"deleteConfirmation": "Tem certeza que deseja excluir ?",
"deleteWarning": "Esta ação não pode ser desfeita."
},
@@ -154,9 +154,9 @@
"bulkActions": {
"selected": "{count, plural, =1 {1 arquivo selecionado} other {# arquivos selecionados}}",
"actions": "Ações",
"download": "Baixar Selecionados",
"share": "Compartilhar Selecionados",
"delete": "Excluir Selecionados"
"download": "Baixar selecionados",
"share": "Compartilhar selecionados",
"delete": "Excluir selecionados"
}
},
"footer": {
@@ -175,23 +175,23 @@
"pageTitle": "Esqueceu a Senha"
},
"generateShareLink": {
"generateTitle": "Gerar Link de Compartilhamento",
"updateTitle": "Atualizar Link de Compartilhamento",
"generateTitle": "Gerar link de compartilhamento",
"updateTitle": "Atualizar link de compartilhamento",
"generateDescription": "Gere um link para compartilhar seus arquivos",
"updateDescription": "Atualize o alias deste link de compartilhamento",
"aliasPlaceholder": "Digite o alias",
"linkReady": "Seu link de compartilhamento está pronto:",
"generateButton": "Gerar Link",
"updateButton": "Atualizar Link",
"copyButton": "Copiar Link",
"generateButton": "Gerar link",
"updateButton": "Atualizar link",
"copyButton": "Copiar link",
"success": "Link gerado com sucesso",
"error": "Erro ao gerar link",
"copied": "Link copiado para a área de transferência"
},
"shareFile": {
"title": "Compartilhar Arquivo",
"linkTitle": "Gerar Link",
"nameLabel": "Nome do Compartilhamento",
"title": "Compartilhar arquivo",
"linkTitle": "Gerar link",
"nameLabel": "Nome do compartilhamento",
"namePlaceholder": "Digite o nome do compartilhamento",
"descriptionLabel": "Descrição",
"descriptionPlaceholder": "Digite uma descrição (opcional)",
@@ -199,16 +199,16 @@
"expirationPlaceholder": "DD/MM/AAAA HH:MM",
"maxViewsLabel": "Máximo de Visualizações",
"maxViewsPlaceholder": "Deixe vazio para ilimitado",
"passwordProtection": "Protegido por Senha",
"passwordProtection": "Protegido por senha",
"passwordLabel": "Senha",
"passwordPlaceholder": "Digite a senha",
"linkDescription": "Gere um link personalizado para compartilhar o arquivo",
"aliasLabel": "Alias do Link",
"aliasLabel": "Alias do link",
"aliasPlaceholder": "Digite um alias personalizado",
"linkReady": "Seu link de compartilhamento está pronto:",
"createShare": "Criar Compartilhamento",
"generateLink": "Gerar Link",
"copyLink": "Copiar Link"
"createShare": "Criar compartilhamento",
"generateLink": "Gerar link",
"copyLink": "Copiar link"
},
"home": {
"description": "A alternativa open-source ao WeTransfer. Compartilhe arquivos com segurança, sem rastreamento ou limitações.",
@@ -223,7 +223,9 @@
},
"login": {
"welcome": "Bem-vindo ao",
"signInToContinue": "Entre para continuar",
"signInToContinue": "Faça login para continuar",
"emailOrUsernameLabel": "E-mail ou Nome de Usuário",
"emailOrUsernamePlaceholder": "Digite seu e-mail ou nome de usuário",
"emailLabel": "Endereço de E-mail",
"emailPlaceholder": "Digite seu e-mail",
"passwordLabel": "Senha",
@@ -231,18 +233,18 @@
"signIn": "Entrar",
"signingIn": "Entrando...",
"forgotPassword": "Esqueceu a senha?",
"pageTitle": "Entrar",
"pageTitle": "Login",
"or": "ou",
"continueWithSSO": "Continuar com SSO",
"processing": "Processando autenticação..."
},
"logo": {
"labels": {
"appLogo": "Logo do Aplicativo"
"appLogo": "Logo do aplicativo"
},
"buttons": {
"upload": "Enviar Logo",
"remove": "Remover Logo"
"upload": "Enviar logo",
"remove": "Remover logo"
},
"messages": {
"uploadSuccess": "Logo enviado com sucesso",
@@ -254,11 +256,11 @@
}
},
"navbar": {
"logoAlt": "Logo do Aplicativo",
"logoAlt": "Logo do aplicativo",
"profileMenu": "Menu do Perfil",
"profile": "Perfil",
"settings": "Configurações",
"usersManagement": "Gerenciar Usuários",
"usersManagement": "Gerenciar usuários",
"logout": "Sair"
},
"navigation": {
@@ -752,7 +754,15 @@
"title": "Uso de Armazenamento",
"ariaLabel": "Barra de progresso do uso de armazenamento",
"used": "usado",
"available": "disponível"
"available": "disponível",
"loading": "Carregando...",
"retry": "Tentar novamente",
"errors": {
"title": "Informações de armazenamento indisponíveis",
"detectionFailed": "Não foi possível detectar o espaço em disco. Isso pode ser devido a problemas de configuração do sistema ou permissões insuficientes.",
"serverError": "Ocorreu um erro no servidor ao recuperar as informações de armazenamento. Por favor, tente novamente mais tarde.",
"unknown": "Ocorreu um erro inesperado ao carregar as informações de armazenamento."
}
},
"theme": {
"toggle": "Alternar tema",
@@ -770,6 +780,7 @@
"uploadProgress": "Progresso do upload",
"upload": "Enviar",
"startUploads": "Iniciar Uploads",
"retry": "Tentar Novamente",
"finish": "Concluir",
"success": "Arquivo enviado com sucesso",
"allSuccess": "{count, plural, =1 {Arquivo enviado com sucesso} other {# arquivos enviados com sucesso}}",
@@ -857,18 +868,15 @@
}
},
"validation": {
"invalidEmail": "Endereço de email inválido",
"passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
"firstNameRequired": "Nome é obrigatório",
"lastNameRequired": "Sobrenome é obrigatório",
"usernameLength": "Nome de usuário deve ter pelo menos 3 caracteres",
"usernameSpaces": "Nome de usuário não pode conter espaços",
"invalidEmail": "Por favor, insira um endereço de e-mail válido",
"passwordLength": "A senha deve ter pelo menos 8 caracteres",
"passwordsMatch": "As senhas não coincidem",
"passwordsMatch": "As senhas devem coincidir",
"emailRequired": "Email é obrigatório",
"emailOrUsernameRequired": "E-mail ou nome de usuário é obrigatório",
"passwordRequired": "Senha é obrigatória",
"required": "Este campo é obrigatório",
"nameRequired": "Nome é obrigatório"
"passwordMinLength": "A senha deve ter pelo menos 6 caracteres",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
},
"bulkDownload": {
"title": "Download em Lote",
@@ -937,8 +945,8 @@
"noExpiration": "Este compartilhamento nunca expirará e permanecerá acessível indefinidamente.",
"title": "Sobre expiração:"
},
"enableExpiration": "Habilitar Expiração",
"title": "Configurações de Expiração do Compartilhamento",
"enableExpiration": "Habilitar expiração",
"title": "Configurações de expiração do compartilhamento",
"subtitle": "Configurar quando este compartilhamento expirará",
"validation": {
"dateMustBeFuture": "A data de expiração deve estar no futuro",
@@ -949,7 +957,7 @@
"updateFailed": "Falha ao atualizar configurações de expiração"
},
"expires": "Expira:",
"expirationDate": "Data de Expiração"
"expirationDate": "Data de expiração"
},
"auth": {
"errors": {
@@ -961,10 +969,10 @@
}
},
"reverseShares": {
"pageTitle": "Receber Arquivos",
"pageTitle": "Receber arquivos",
"search": {
"title": "Gerenciar Links de Recebimento",
"createButton": "Criar Link",
"title": "Gerenciar links de recebimento",
"createButton": "Criar link",
"placeholder": "Buscar links de recebimento...",
"results": "Encontrados {filtered} de {total} links de recebimento"
},
@@ -974,13 +982,13 @@
"status": "status",
"access": "acesso",
"description": "Descrição",
"pageLayout": "Layout da Página",
"pageLayout": "Layout da página",
"security": "Segurança & Status",
"limits": "Limites",
"maxFiles": "Máximo de Arquivos",
"maxFileSize": "Tamanho Máximo",
"allowedTypes": "Tipos Permitidos",
"filesReceived": "Arquivos Recebidos",
"maxFiles": "Máximo de arquivos",
"maxFileSize": "Tamanho máximo",
"allowedTypes": "Tipos permitidos",
"filesReceived": "Arquivos recebidos",
"fileLimit": "Limite de Arquivos",
"noLimit": "Sem limite",
"noLinkCreated": "Nenhum link criado",
@@ -1269,6 +1277,7 @@
"linkInactive": "Este link está inativo.",
"linkExpired": "Este link expirou.",
"uploadFailed": "Erro ao enviar arquivo",
"retry": "Tentar Novamente",
"fileTooLarge": "Arquivo muito grande. Tamanho máximo: {maxSize}",
"fileTypeNotAllowed": "Tipo de arquivo não permitido. Tipos aceitos: {allowedTypes}",
"maxFilesExceeded": "Máximo de {maxFiles} arquivos permitidos",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Добро пожаловать в",
"signInToContinue": "Войдите, чтобы продолжить",
"emailOrUsernameLabel": "Электронная почта или имя пользователя",
"emailOrUsernamePlaceholder": "Введите электронную почту или имя пользователя",
"emailLabel": "Адрес электронной почты",
"emailPlaceholder": "Введите вашу электронную почту",
"passwordLabel": "Пароль",
@@ -730,7 +732,15 @@
"title": "Использование хранилища",
"ariaLabel": "Индикатор использования хранилища",
"used": "Использовано",
"available": "Доступно"
"available": "Доступно",
"loading": "Загрузка...",
"retry": "Повторить",
"errors": {
"title": "Информация о хранилище недоступна",
"detectionFailed": "Не удалось определить свободное место на диске. Это может быть связано с проблемами конфигурации системы или недостаточными правами доступа.",
"serverError": "Произошла ошибка сервера при получении информации о хранилище. Пожалуйста, повторите попытку позже.",
"unknown": "Произошла непредвиденная ошибка при загрузке информации о хранилище."
}
},
"theme": {
"toggle": "Переключить тему",
@@ -748,6 +758,7 @@
"uploadProgress": "Прогресс загрузки",
"upload": "Загрузить",
"startUploads": "Начать Загрузку",
"retry": "Повторить",
"finish": "Завершить",
"success": "Файл успешно загружен",
"allSuccess": "{count, plural, =1 {Файл успешно загружен} other {# файлов успешно загружено}}",
@@ -844,6 +855,7 @@
"passwordLength": "Пароль должен содержать не менее 8 символов",
"passwordsMatch": "Пароли не совпадают",
"emailRequired": "Требуется электронная почта",
"emailOrUsernameRequired": "Электронная почта или имя пользователя обязательно",
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
@@ -1269,6 +1281,7 @@
"linkInactive": "Эта ссылка неактивна.",
"linkExpired": "Срок действия этой ссылки истек.",
"uploadFailed": "Ошибка при загрузке файла",
"retry": "Повторить",
"fileTooLarge": "Файл слишком большой. Максимальный размер: {maxSize}",
"fileTypeNotAllowed": "Тип файла не разрешен. Разрешенные типы: {allowedTypes}",
"maxFilesExceeded": "Максимально разрешено {maxFiles} файлов",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "Hoş geldiniz'e",
"signInToContinue": "Devam etmek için oturum açın",
"emailOrUsernameLabel": "E-posta veya Kullanıcı Adı",
"emailOrUsernamePlaceholder": "E-posta veya kullanıcı adınızı girin",
"emailLabel": "E-posta Adresi",
"emailPlaceholder": "E-posta adresinizi girin",
"passwordLabel": "Şifre",
@@ -730,7 +732,15 @@
"title": "Depolama Kullanımı",
"ariaLabel": "Depolama kullanım ilerleme çubuğu",
"used": "kullanıldı",
"available": "kullanılabilir"
"available": "kullanılabilir",
"loading": "Yükleniyor...",
"retry": "Tekrar Dene",
"errors": {
"title": "Depolama bilgisi kullanılamıyor",
"detectionFailed": "Disk alanı tespit edilemiyor. Bu, sistem yapılandırma sorunlarından veya yetersiz izinlerden kaynaklanıyor olabilir.",
"serverError": "Depolama bilgisi alınırken sunucu hatası oluştu. Lütfen daha sonra tekrar deneyin.",
"unknown": "Depolama bilgisi yüklenirken beklenmeyen bir hata oluştu."
}
},
"theme": {
"toggle": "Temayı değiştir",
@@ -748,6 +758,7 @@
"uploadProgress": "Yükleme ilerlemesi",
"upload": "Yükle",
"startUploads": "Yüklemeleri Başlat",
"retry": "Tekrar Dene",
"finish": "Bitir",
"success": "Dosya başarıyla yüklendi",
"allSuccess": "{count, plural, =1 {Dosya başarıyla yüklendi} other {# dosya başarıyla yüklendi}}",
@@ -844,6 +855,7 @@
"passwordLength": "Şifre en az 8 karakter olmalıdır",
"passwordsMatch": "Şifreler eşleşmiyor",
"emailRequired": "E-posta gerekli",
"emailOrUsernameRequired": "E-posta veya kullanıcı adı gereklidir",
"passwordRequired": "Şifre gerekli",
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
@@ -1269,6 +1281,7 @@
"linkInactive": "Bu bağlantı pasif durumda.",
"linkExpired": "Bu bağlantının süresi doldu.",
"uploadFailed": "Dosya yüklenirken hata oluştu",
"retry": "Tekrar Dene",
"fileTooLarge": "Dosya çok büyük. Maksimum boyut: {maxSize}",
"fileTypeNotAllowed": "Dosya türüne izin verilmiyor. İzin verilen türler: {allowedTypes}",
"maxFilesExceeded": "Maksimum {maxFiles} dosyaya izin veriliyor",

View File

@@ -202,6 +202,8 @@
"login": {
"welcome": "欢迎您",
"signInToContinue": "请登录以继续",
"emailOrUsernameLabel": "电子邮件或用户名",
"emailOrUsernamePlaceholder": "请输入您的电子邮件或用户名",
"emailLabel": "电子邮件地址",
"emailPlaceholder": "请输入您的电子邮件",
"passwordLabel": "密码",
@@ -730,7 +732,15 @@
"title": "存储使用情况",
"ariaLabel": "存储使用进度条",
"used": "已使用:",
"available": "可用"
"available": "可用",
"loading": "加载中...",
"retry": "重试",
"errors": {
"title": "存储信息不可用",
"detectionFailed": "无法检测磁盘空间。这可能是由于系统配置问题或权限不足。",
"serverError": "检索存储信息时发生服务器错误。请稍后重试。",
"unknown": "加载存储信息时发生意外错误。"
}
},
"theme": {
"toggle": "切换主题",
@@ -748,6 +758,7 @@
"uploadProgress": "上传进度",
"upload": "上传",
"startUploads": "开始上传",
"retry": "重试",
"finish": "完成",
"success": "文件上传成功",
"allSuccess": "{count, plural, =1 {文件上传成功} other {# 个文件上传成功}}",
@@ -844,6 +855,7 @@
"passwordLength": "密码至少需要8个字符",
"passwordsMatch": "密码不匹配",
"emailRequired": "电子邮件为必填项",
"emailOrUsernameRequired": "电子邮件或用户名是必填项",
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
@@ -1269,6 +1281,7 @@
"linkInactive": "此链接已停用。",
"linkExpired": "此链接已过期。",
"uploadFailed": "上传文件时出错",
"retry": "重试",
"fileTooLarge": "文件太大。最大大小:{maxSize}",
"fileTypeNotAllowed": "不允许的文件类型。允许的类型:{allowedTypes}",
"maxFilesExceeded": "最多允许 {maxFiles} 个文件",

View File

@@ -156,13 +156,11 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
const { file } = fileWithProgress;
try {
// Start upload
updateFileStatus(index, {
status: FILE_STATUS.UPLOADING,
progress: UPLOAD_PROGRESS.INITIAL,
});
// Generate object name and get presigned URL
const objectName = generateObjectName(file.name);
const presignedResponse = await getPresignedUrlForUploadByAlias(
alias,
@@ -170,16 +168,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
password ? { password } : undefined
);
// Upload to storage
await uploadFileToStorage(file, presignedResponse.data.url);
// Update progress
updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE });
// Register file upload
await registerUploadedFile(file, objectName);
// Mark as successful
updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
} catch (error: any) {
console.error("Upload error:", error);
@@ -243,7 +237,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
);
const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS);
// Call onUploadSuccess when all files are processed and there are successful uploads
useEffect(() => {
if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) {
onUploadSuccess?.();
@@ -266,7 +259,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
};
const renderFileRestrictions = () => {
// Calculate remaining files that can be uploaded
const calculateRemainingFiles = (): number => {
if (!reverseShare.maxFiles) return 0;
const currentTotal = reverseShare.currentFileCount + files.length;
@@ -339,13 +331,34 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
<IconX className="h-4 w-4" />
</Button>
)}
{fileWithProgress.status === FILE_STATUS.ERROR && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setFiles((prev) =>
prev.map((file, i) =>
i === index ? { ...file, status: FILE_STATUS.PENDING, error: undefined } : file
)
);
}}
disabled={isUploading}
title={t("reverseShares.upload.retry")}
>
<IconUpload className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
);
return (
<div className="space-y-6">
{/* File Drop Zone */}
<div {...getRootProps()} className={getDropzoneStyles()}>
<input {...getInputProps()} />
<IconUpload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
@@ -357,7 +370,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
{renderFileRestrictions()}
</div>
{/* File List */}
{files.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4>
@@ -365,7 +377,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
</div>
)}
{/* User Information */}
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
@@ -409,14 +420,12 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
</div>
</div>
{/* Upload Button */}
<Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default">
{isUploading
? t("reverseShares.upload.form.uploading")
: t("reverseShares.upload.form.uploadButton", { count: files.length })}
</Button>
{/* Success Message */}
{allFilesProcessed && hasSuccessfulUploads && (
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
<p className="text-green-800 dark:text-green-200 font-medium">{t("reverseShares.upload.success.title")}</p>

View File

@@ -66,7 +66,6 @@ export function WeTransferStatusMessage({
}: WeTransferStatusMessageProps) {
const t = useTranslations();
// Map message types to variants
const getVariant = (): "success" | "warning" | "error" | "info" | "neutral" => {
switch (type) {
case MESSAGE_TYPES.SUCCESS:

View File

@@ -12,13 +12,11 @@ import { FileUploadSection } from "./file-upload-section";
import { WeTransferStatusMessage } from "./shared/status-message";
import { TransparentFooter } from "./transparent-footer";
// Função para escolher uma imagem aleatória
const getRandomBackgroundImage = (): string => {
const randomIndex = Math.floor(Math.random() * BACKGROUND_IMAGES.length);
return BACKGROUND_IMAGES[randomIndex];
};
// Hook para gerenciar a imagem de background
const useBackgroundImage = () => {
const [selectedImage, setSelectedImage] = useState<string>("");
const [imageLoaded, setImageLoaded] = useState(false);
@@ -42,7 +40,6 @@ const useBackgroundImage = () => {
return { selectedImage, imageLoaded };
};
// Componente para controles do header
const HeaderControls = () => (
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2">
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
@@ -54,7 +51,6 @@ const HeaderControls = () => (
</div>
);
// Componente para o fundo com imagem
const BackgroundLayer = ({ selectedImage, imageLoaded }: { selectedImage: string; imageLoaded: boolean }) => (
<>
<div className="absolute inset-0 z-0 bg-background" />
@@ -162,18 +158,15 @@ export function WeTransferLayout({
<BackgroundLayer selectedImage={selectedImage} imageLoaded={imageLoaded} />
<HeaderControls />
{/* Loading indicator */}
{!imageLoaded && (
<div className="absolute inset-0 z-30 flex items-center justify-center">
<div className="animate-pulse text-white/70 text-sm">{t("reverseShares.upload.layout.loading")}</div>
</div>
)}
{/* Main Content */}
<div className="relative z-30 min-h-screen flex items-center justify-start p-4 md:p-8 lg:p-12 xl:p-16">
<div className="w-full max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-white dark:bg-black rounded-2xl shadow-2xl p-6 md:p-8 backdrop-blur-sm border border-white/20">
{/* Header */}
<div className="text-left mb-6 md:mb-8">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white mb-2">
{reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")}
@@ -183,7 +176,6 @@ export function WeTransferLayout({
)}
</div>
{/* Upload Section */}
{getUploadSectionContent()}
</div>
</div>

View File

@@ -1,4 +1,3 @@
// HTTP Status Constants
export const HTTP_STATUS = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
@@ -6,13 +5,11 @@ export const HTTP_STATUS = {
GONE: 410,
} as const;
// Error Messages
export const ERROR_MESSAGES = {
PASSWORD_REQUIRED: "Password required",
INVALID_PASSWORD: "Invalid password",
} as const;
// Error types
export type ErrorType = "inactive" | "notFound" | "expired" | "generic" | null;
export const STATUS_VARIANTS = {

View File

@@ -17,7 +17,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
const router = useRouter();
const t = useTranslations();
// States
const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
@@ -25,7 +24,6 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
const [hasUploadedSuccessfully, setHasUploadedSuccessfully] = useState(false);
const [error, setError] = useState<{ type: ErrorType }>({ type: null });
// Utility functions
const redirectToHome = () => router.push("/");
const checkIfMaxFilesReached = (reverseShareData: ReverseShareInfo): boolean => {
@@ -109,23 +107,19 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
}
}, [alias]);
// Computed values
const isMaxFilesReached = reverseShare ? checkIfMaxFilesReached(reverseShare) : false;
const isWeTransferLayout = reverseShare?.pageLayout === "WETRANSFER";
const hasError = error.type !== null || (!reverseShare && !isLoading && !isPasswordModalOpen);
// Error state booleans for backward compatibility
const isLinkInactive = error.type === "inactive";
const isLinkNotFound = error.type === "notFound" || (!reverseShare && !isLoading && !isPasswordModalOpen);
const isLinkExpired = error.type === "expired";
return {
// Data
reverseShare,
currentPassword,
alias,
// States
isLoading,
isPasswordModalOpen,
hasUploadedSuccessfully,
@@ -134,12 +128,10 @@ export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
isWeTransferLayout,
hasError,
// Error states (for backward compatibility)
isLinkInactive,
isLinkNotFound,
isLinkExpired,
// Actions
handlePasswordSubmit,
handlePasswordModalClose,
handleUploadSuccess,

View File

@@ -27,19 +27,16 @@ export default function ReverseShareUploadPage() {
handleUploadSuccess,
} = useReverseShareUpload({ alias: shareAlias });
// Loading state
if (isLoading) {
return <LoadingScreen />;
}
// Password required state
if (isPasswordModalOpen) {
return (
<PasswordModal isOpen={isPasswordModalOpen} onSubmit={handlePasswordSubmit} onClose={handlePasswordModalClose} />
);
}
// Error states or missing data - always use DefaultLayout for simplicity
if (hasError) {
return (
<DefaultLayout
@@ -56,7 +53,6 @@ export default function ReverseShareUploadPage() {
);
}
// Render appropriate layout for normal states
if (isWeTransferLayout) {
return (
<WeTransferLayout

View File

@@ -37,20 +37,12 @@ import { ReverseShare } from "../hooks/use-reverse-shares";
import { FileSizeInput } from "./file-size-input";
import { FileTypesTagsInput } from "./file-types-tags-input";
// Constants
const DEFAULT_VALUES = {
EMPTY_STRING: "",
ZERO_STRING: "0",
PAGE_LAYOUT: "DEFAULT" as const,
} as const;
const FORM_SECTIONS = {
BASIC_INFO: "basicInfo",
EXPIRATION: "expiration",
FILE_LIMITS: "fileLimits",
PASSWORD: "password",
} as const;
interface EditReverseShareFormData {
name: string;
description?: string;
@@ -168,7 +160,6 @@ export function EditReverseShareModal({
);
}
// Helper functions
function getFormDefaultValues(): EditReverseShareFormData {
return {
name: DEFAULT_VALUES.EMPTY_STRING,
@@ -224,19 +215,16 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
isActive: data.isActive,
};
// Add optional fields
if (data.description?.trim()) {
payload.description = data.description.trim();
}
// Handle expiration
if (data.hasExpiration && data.expiration) {
payload.expiration = new Date(data.expiration).toISOString();
} else if (!data.hasExpiration) {
payload.expiration = undefined;
}
// Handle file limits
if (data.hasFileLimits) {
payload.maxFiles = parsePositiveIntegerOrNull(data.maxFiles);
payload.maxFileSize = parsePositiveIntegerOrNull(data.maxFileSize);
@@ -245,10 +233,8 @@ function buildUpdatePayload(data: EditReverseShareFormData, id: string): UpdateR
payload.maxFileSize = null;
}
// Handle allowed file types
payload.allowedFileTypes = data.allowedFileTypes?.trim() || null;
// Handle password
if (data.hasPassword && data.password) {
payload.password = data.password;
} else if (!data.hasPassword) {
@@ -289,7 +275,6 @@ function createLimitCheckbox(id: string, checked: boolean, onChange: (checked: b
);
}
// Section Components
function BasicInfoSection({ form, t }: { form: any; t: any }) {
return (
<div className="space-y-4">
@@ -442,7 +427,6 @@ function FileLimitsSection({
{hasFileLimits && (
<div className="space-y-4">
{/* Max Files Field */}
<FormField
control={form.control}
name="maxFiles"
@@ -479,7 +463,6 @@ function FileLimitsSection({
)}
/>
{/* Max File Size Field */}
<FormField
control={form.control}
name="maxFileSize"
@@ -515,7 +498,6 @@ function FileLimitsSection({
)}
/>
{/* Allowed File Types Field */}
<FormField
control={form.control}
name="allowedFileTypes"

View File

@@ -34,10 +34,8 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
const value = numBytes / multiplier;
if (value >= 1) {
// Se o valor é >= 1 nesta unidade, usar ela
const rounded = Math.round(value * 100) / 100; // Arredonda para 2 casas decimais
const rounded = Math.round(value * 100) / 100;
// Se está muito próximo de um inteiro, usar inteiro
if (Math.abs(rounded - Math.round(rounded)) < 0.01) {
return { value: Math.round(rounded).toString(), unit };
} else {
@@ -46,7 +44,6 @@ function bytesToHumanReadable(bytes: string): { value: string; unit: Unit } {
}
}
// Fallback para MB
const mbValue = numBytes / UNIT_MULTIPLIERS.MB;
return { value: mbValue.toFixed(2), unit: "MB" as Unit };
}
@@ -92,7 +89,6 @@ export function FileSizeInput({ value, onChange, disabled = false, error, placeh
};
const handleUnitChange = (newUnit: Unit) => {
// Ignorar valores vazios ou inválidos que podem vir do Select quando atualizado programaticamente
if (!newUnit || !["MB", "GB", "TB", "PB"].includes(newUnit)) {
return;
}

View File

@@ -24,7 +24,6 @@ export function FileTypesTagsInput({
const [inputValue, setInputValue] = useState("");
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
// Separadores: Enter, espaço, vírgula, pipe, traço
if (e.key === "Enter" || e.key === " " || e.key === "," || e.key === "|" || e.key === "-") {
e.preventDefault();
addTag();
@@ -32,7 +31,6 @@ export function FileTypesTagsInput({
e.preventDefault();
removeTag(value.length - 1);
} else if (e.key === ".") {
// Impedir pontos
e.preventDefault();
}
};
@@ -51,7 +49,6 @@ export function FileTypesTagsInput({
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Remover pontos e forçar minúsculo
const sanitizedValue = e.target.value.replace(/\./g, "").toLowerCase();
setInputValue(sanitizedValue);
};

View File

@@ -54,14 +54,11 @@ export function GenerateAliasModal({
},
});
// Atualiza o valor padrão quando o reverseShare muda
React.useEffect(() => {
if (reverseShare) {
if (reverseShare.alias?.alias) {
// Se já tem alias, usa o existente
form.setValue("alias", reverseShare.alias.alias);
} else {
// Se não tem alias, gera um novo valor padrão
form.setValue("alias", generateDefaultAlias());
}
}
@@ -75,7 +72,6 @@ export function GenerateAliasModal({
await onCreateAlias(reverseShare.id, data.alias);
onClose();
} catch (error) {
// Erro já é tratado no hook
} finally {
setIsSubmitting(false);
}
@@ -150,17 +146,15 @@ export function GenerateAliasModal({
className="max-w-full"
{...field}
onChange={(e) => {
// Converter espaços em hífens e remover caracteres não permitidos
const value = e.target.value
.replace(/\s+/g, "-") // espaços viram hífens
.replace(/[^a-zA-Z0-9-_]/g, "") // remove caracteres não permitidos
.toLowerCase(); // converte para minúsculo
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9-_]/g, "")
.toLowerCase();
field.onChange(value);
}}
/>
</FormControl>
{/* Preview do link */}
{field.value && field.value.length >= 3 && (
<div className="mt-2 p-2 bg-primary/5 border border-primary/20 rounded-md overflow-hidden">
<label className="text-xs text-muted-foreground block mb-1">

View File

@@ -25,7 +25,6 @@ import { getFileIcon } from "@/utils/file-icons";
import { ReverseShare } from "../hooks/use-reverse-shares";
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
// Types
interface EditingState {
fileId: string;
field: string;
@@ -36,7 +35,6 @@ interface HoverState {
field: string;
}
// Custom Hooks
function useFileEdit() {
const [editingFile, setEditingFile] = useState<EditingState | null>(null);
const [editValue, setEditValue] = useState("");
@@ -74,7 +72,6 @@ function useFileEdit() {
};
}
// Utility Functions
const formatFileSize = (sizeString: string) => {
const sizeInBytes = parseInt(sizeString);
if (sizeInBytes === 0) return "0 B";
@@ -122,7 +119,6 @@ const getSenderInitials = (file: ReverseShareFile) => {
return "?";
};
// Components
interface EditableFieldProps {
file: ReverseShareFile;
field: "name" | "description";

View File

@@ -103,7 +103,6 @@ export function ReverseShareCard({
const { field } = editingField;
let processedValue: string | number | null | boolean = editValue;
// Processar valores específicos
if (field === "isActive") {
processedValue = editValue === "true";
}

View File

@@ -18,7 +18,6 @@ import type {
UpdateReverseShareBody,
} from "@/http/endpoints/reverse-shares/types";
// Tipo baseado na resposta da API
export type ReverseShare = ListUserReverseSharesResult["data"]["reverseShares"][0];
export function useReverseShares() {
@@ -62,7 +61,6 @@ export function useReverseShares() {
setReverseShares(sortedReverseShares);
// Atualiza o reverseShare específico que está sendo visualizado
const updatedReverseShare = allReverseShares.find((rs) => rs.id === id);
if (updatedReverseShare) {
if (reverseShareToViewFiles && reverseShareToViewFiles.id === id) {
@@ -83,13 +81,11 @@ export function useReverseShares() {
const response = await createReverseShare(data);
const newReverseShare = response.data.reverseShare;
// Adiciona ao estado local
setReverseShares((prev) => [newReverseShare as ReverseShare, ...prev]);
toast.success(t("reverseShares.messages.createSuccess"));
setIsCreateModalOpen(false);
// Automaticamente abre o modal de alias para o reverse share criado
setReverseShareToGenerateLink(newReverseShare as ReverseShare);
return newReverseShare;
@@ -113,7 +109,6 @@ export function useReverseShares() {
updatedAt: new Date().toISOString(),
};
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) =>
rs.id === reverseShareId
@@ -125,7 +120,6 @@ export function useReverseShares() {
)
);
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === reverseShareId) {
setReverseShareToViewDetails({
...reverseShareToViewDetails,
@@ -145,7 +139,6 @@ export function useReverseShares() {
try {
await deleteReverseShare(reverseShare.id);
// Remove do estado local
setReverseShares((prev) => prev.filter((rs) => rs.id !== reverseShare.id));
toast.success(t("reverseShares.messages.deleteSuccess"));
@@ -163,7 +156,6 @@ export function useReverseShares() {
const response = await updateReverseShare(data);
const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) => (rs.id === data.id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
);
@@ -186,12 +178,10 @@ export function useReverseShares() {
const response = await updateReverseSharePassword(id, payload);
const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
);
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
}
@@ -208,12 +198,10 @@ export function useReverseShares() {
const response = await updateReverseShare(payload);
const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
);
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
}
@@ -232,12 +220,10 @@ export function useReverseShares() {
const response = await updateReverseShare(payload);
const updatedReverseShare = response.data.reverseShare;
// Atualiza o estado local
setReverseShares((prev) =>
prev.map((rs) => (rs.id === id ? ({ ...rs, ...updatedReverseShare } as ReverseShare) : rs))
);
// Atualiza o reverseShare que está sendo visualizado no modal de detalhes
if (reverseShareToViewDetails && reverseShareToViewDetails.id === id) {
setReverseShareToViewDetails({ ...reverseShareToViewDetails, ...updatedReverseShare } as ReverseShare);
}
@@ -256,7 +242,6 @@ export function useReverseShares() {
loadReverseShares();
}, []);
// Sincroniza o reverseShareToViewDetails com a lista atualizada
useEffect(() => {
if (reverseShareToViewDetails) {
const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewDetails.id);
@@ -266,7 +251,6 @@ export function useReverseShares() {
}
}, [reverseShares, reverseShareToViewDetails?.id]);
// Sincroniza o reverseShareToViewFiles com a lista atualizada
useEffect(() => {
if (reverseShareToViewFiles) {
const updatedReverseShare = reverseShares.find((rs) => rs.id === reverseShareToViewFiles.id);

View File

@@ -4,7 +4,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ obje
const { objectPath } = await params;
const cookieHeader = req.headers.get("cookie");
// Reconstruct the full objectName from the path segments
const objectName = objectPath.join("/");
const url = `${process.env.API_BASE_URL}/files/${encodeURIComponent(objectName)}/download`;

View File

@@ -3,12 +3,15 @@ import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest, { params }: { params: Promise<{ shareId: string }> }) {
const cookieHeader = req.headers.get("cookie");
const { shareId } = await params;
const body = await req.text();
const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/recipients/notify`, {
const apiRes = await fetch(`${process.env.API_BASE_URL}/shares/${shareId}/notify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body: body,
redirect: "manual",
});

View File

@@ -14,8 +14,8 @@ export default function AuthCallbackPage() {
if (token) {
Cookies.set("token", token, {
path: "/",
secure: false,
sameSite: "strict",
secure: window.location.protocol === "https:",
sameSite: window.location.protocol === "https:" ? "lax" : "strict",
httpOnly: false,
});

View File

@@ -1,14 +1,76 @@
import { IconDatabaseCog } from "@tabler/icons-react";
import { IconAlertCircle, IconDatabaseCog, IconRefresh } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import type { StorageUsageProps } from "../types";
import { formatStorageSize } from "../utils/format-storage-size";
export function StorageUsage({ diskSpace }: StorageUsageProps) {
export function StorageUsage({ diskSpace, diskSpaceError, onRetry }: StorageUsageProps) {
const t = useTranslations();
const getErrorMessage = (error: string) => {
switch (error) {
case "disk_detection_failed":
return t("storageUsage.errors.detectionFailed");
case "server_error":
return t("storageUsage.errors.serverError");
default:
return t("storageUsage.errors.unknown");
}
};
if (diskSpaceError) {
return (
<Card className="w-full">
<CardContent className="">
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<IconDatabaseCog className="text-gray-500" size={24} />
{t("storageUsage.title")}
</h2>
<div className="flex flex-col gap-3 py-4">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<IconAlertCircle size={20} />
<span className="text-sm font-medium">{t("storageUsage.errors.title")}</span>
</div>
<p className="text-sm text-muted-foreground">{getErrorMessage(diskSpaceError)}</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry} className="w-fit">
<IconRefresh size={16} className="mr-2" />
{t("storageUsage.retry")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
);
}
if (!diskSpace) {
return (
<Card className="w-full">
<CardContent className="">
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold flex items-center gap-2">
<IconDatabaseCog className="text-gray-500" size={24} />
{t("storageUsage.title")}
</h2>
<div className="flex flex-col gap-2">
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{t("storageUsage.loading")}</span>
<span>{t("storageUsage.loading")}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full">
<CardContent className="">

View File

@@ -18,6 +18,7 @@ export function useDashboard() {
diskAvailableGB: number;
uploadAllowed: boolean;
} | null>(null);
const [diskSpaceError, setDiskSpaceError] = useState<string | null>(null);
const [recentFiles, setRecentFiles] = useState<any[]>([]);
const [recentShares, setRecentShares] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -34,24 +35,44 @@ export function useDashboard() {
const loadDashboardData = async () => {
try {
const [diskSpaceRes, filesRes, sharesRes] = await Promise.all([getDiskSpace(), listFiles(), listUserShares()]);
const loadDiskSpace = async () => {
try {
const diskSpaceRes = await getDiskSpace();
setDiskSpace(diskSpaceRes.data);
setDiskSpaceError(null);
} catch (error: any) {
console.warn("Failed to load disk space:", error);
setDiskSpace(null);
setDiskSpace(diskSpaceRes.data);
if (error.response?.status === 503 && error.response?.data?.code === "DISK_SPACE_DETECTION_FAILED") {
setDiskSpaceError("disk_detection_failed");
} else if (error.response?.status >= 500) {
setDiskSpaceError("server_error");
} else {
setDiskSpaceError("unknown_error");
}
}
};
const allFiles = filesRes.data.files || [];
const sortedFiles = [...allFiles].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const loadFilesAndShares = async () => {
const [filesRes, sharesRes] = await Promise.all([listFiles(), listUserShares()]);
setRecentFiles(sortedFiles.slice(0, 5));
const allFiles = filesRes.data.files || [];
const sortedFiles = [...allFiles].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setRecentFiles(sortedFiles.slice(0, 5));
const allShares = sharesRes.data.shares || [];
const sortedShares = [...allShares].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
const allShares = sharesRes.data.shares || [];
const sortedShares = [...allShares].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
setRecentShares(sortedShares.slice(0, 5));
};
setRecentShares(sortedShares.slice(0, 5));
await Promise.allSettled([loadDiskSpace(), loadFilesAndShares()]);
} catch (error) {
console.error("Critical dashboard error:", error);
toast.error(t("dashboard.loadError"));
} finally {
setIsLoading(false);
@@ -76,6 +97,7 @@ export function useDashboard() {
return {
isLoading,
diskSpace,
diskSpaceError,
recentFiles,
recentShares,
modals: {

View File

@@ -19,6 +19,7 @@ export default function DashboardPage() {
const {
isLoading,
diskSpace,
diskSpaceError,
recentFiles,
recentShares,
modals,
@@ -32,6 +33,10 @@ export default function DashboardPage() {
return <LoadingScreen />;
}
const handleRetryDiskSpace = async () => {
await loadDashboardData();
};
return (
<ProtectedRoute>
<FileManagerLayout
@@ -40,7 +45,7 @@ export default function DashboardPage() {
showBreadcrumb={false}
title={t("dashboard.pageTitle")}
>
<StorageUsage diskSpace={diskSpace} />
<StorageUsage diskSpace={diskSpace} diskSpaceError={diskSpaceError} onRetry={handleRetryDiskSpace} />
<QuickAccessCards />
<div className="flex flex-col gap-6">

View File

@@ -24,6 +24,8 @@ export interface StorageUsageProps {
diskAvailableGB: number;
uploadAllowed: boolean;
} | null;
diskSpaceError?: string | null;
onRetry?: () => void;
}
export interface DashboardModalsProps {

View File

@@ -23,7 +23,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
emailOrUsername: "",
password: "",
},
});
@@ -37,18 +37,18 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
</p>
);
const renderEmailField = () => (
const renderEmailOrUsernameField = () => (
<FormField
control={form.control}
name="email"
name="emailOrUsername"
render={({ field }) => (
<FormItem>
<FormLabel>{t("login.emailLabel")}</FormLabel>
<FormLabel>{t("login.emailOrUsernameLabel")}</FormLabel>
<FormControl className="-mb-1">
<Input
{...field}
type="email"
placeholder={t("login.emailPlaceholder")}
type="text"
placeholder={t("login.emailOrUsernamePlaceholder")}
disabled={isSubmitting}
className="bg-transparent backdrop-blur-md"
/>
@@ -89,7 +89,7 @@ export function LoginForm({ error, isVisible, onToggleVisibility, onSubmit }: Lo
{renderErrorMessage()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
{renderEmailField()}
{renderEmailOrUsernameField()}
{renderPasswordField()}
<Button className="w-full mt-4 cursor-pointer" variant="default" size="lg" type="submit">
{isSubmitting ? t("login.signingIn") : t("login.signIn")}

View File

@@ -12,7 +12,7 @@ import { getCurrentUser, login } from "@/http/endpoints";
import { LoginFormValues } from "../schemas/schema";
export const loginSchema = z.object({
email: z.string(),
emailOrUsername: z.string(),
password: z.string(),
});
@@ -49,6 +49,17 @@ export function useLogin() {
useEffect(() => {
const checkAuth = async () => {
try {
const appInfoResponse = await fetch("/api/app/info");
const appInfo = await appInfoResponse.json();
if (appInfo.firstUserAccess) {
setUser(null);
setIsAdmin(false);
setIsAuthenticated(false);
setIsInitialized(true);
return;
}
const userResponse = await getCurrentUser();
if (!userResponse?.data?.user) {
throw new Error("No user data");

View File

@@ -5,7 +5,7 @@ type TFunction = ReturnType<typeof useTranslations>;
export const createLoginSchema = (t: TFunction) =>
z.object({
email: z.string().min(1, t("validation.emailRequired")).email(t("validation.invalidEmail")),
emailOrUsername: z.string().min(1, t("validation.emailOrUsernameRequired")),
password: z.string().min(1, t("validation.passwordRequired")),
});

View File

@@ -49,7 +49,7 @@ export function LanguageSwitcher() {
maxAge: COOKIE_MAX_AGE,
path: "/",
sameSite: "lax",
secure: false,
secure: window.location.protocol === "https:",
});
router.refresh();

View File

@@ -1,17 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getDownloadUrl } from "@/http/endpoints";
import { useFilePreview } from "@/hooks/use-file-preview";
import { getFileIcon } from "@/utils/file-icons";
import { FilePreviewRenderer } from "./previews";
interface FilePreviewModalProps {
isOpen: boolean;
@@ -25,299 +21,7 @@ interface FilePreviewModalProps {
export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProps) {
const t = useTranslations();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [videoBlob, setVideoBlob] = useState<string | null>(null);
const [pdfAsBlob, setPdfAsBlob] = useState(false);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [pdfLoadFailed, setPdfLoadFailed] = useState(false);
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
useEffect(() => {
if (isOpen && file.objectName && !isLoadingPreview) {
setIsLoading(true);
setPreviewUrl(null);
setVideoBlob(null);
setPdfAsBlob(false);
setDownloadUrl(null);
setPdfLoadFailed(false);
loadPreview();
}
}, [file.objectName, isOpen]);
useEffect(() => {
return () => {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
}
};
}, [previewUrl, videoBlob]);
useEffect(() => {
if (!isOpen) {
if (previewUrl && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
if (videoBlob && videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(videoBlob);
setVideoBlob(null);
}
}
}, [isOpen]);
const loadPreview = async () => {
if (!file.objectName || isLoadingPreview) return;
setIsLoadingPreview(true);
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const url = response.data.url;
setDownloadUrl(url);
const fileType = getFileType();
if (fileType === "video") {
await loadVideoPreview(url);
} else if (fileType === "audio") {
await loadAudioPreview(url);
} else if (fileType === "pdf") {
await loadPdfPreview(url);
} else {
setPreviewUrl(url);
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setIsLoading(false);
setIsLoadingPreview(false);
}
};
const loadVideoPreview = 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();
const blobUrl = URL.createObjectURL(blob);
setVideoBlob(blobUrl);
} catch (error) {
console.error("Failed to load video as blob:", error);
setPreviewUrl(url);
}
};
const loadAudioPreview = 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();
const blobUrl = URL.createObjectURL(blob);
setPreviewUrl(blobUrl);
} catch (error) {
console.error("Failed to load audio as blob:", error);
setPreviewUrl(url);
}
};
const loadPdfPreview = 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();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setPreviewUrl(blobUrl);
setPdfAsBlob(true);
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setPreviewUrl(url);
setTimeout(() => {
if (!pdfLoadFailed && !pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const handlePdfLoadError = async () => {
if (pdfLoadFailed || pdfAsBlob) return;
setPdfLoadFailed(true);
if (downloadUrl) {
setTimeout(() => {
loadPdfPreview(downloadUrl);
}, 500);
}
};
const handleDownload = async () => {
try {
let downloadUrlToUse = downloadUrl;
if (!downloadUrlToUse) {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
downloadUrlToUse = response.data.url;
}
const fileResponse = await fetch(downloadUrlToUse);
if (!fileResponse.ok) {
throw new Error(`Download failed: ${fileResponse.status} - ${fileResponse.statusText}`);
}
const blob = await fileResponse.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};
const getFileType = () => {
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) return "image";
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) return "audio";
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) return "video";
return "other";
};
const renderPreview = () => {
const fileType = getFileType();
const { icon: FileIcon, color } = getFileIcon(file.name);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
if (!previewUrl && fileType !== "video") {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
switch (fileType) {
case "pdf":
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
/>
) : pdfLoadFailed ? (
<div className="flex items-center justify-center h-full min-h-[600px]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
</div>
</div>
) : (
<div className="w-full h-full min-h-[600px] relative">
<object
data={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={handlePdfLoadError}
>
<iframe
src={`${previewUrl!}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={file.name}
style={{ border: "none" }}
onError={handlePdfLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
case "image":
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={previewUrl!} alt={file.name} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={mediaUrl!} />
</div>
);
case "video":
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={mediaUrl!} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`text-6xl ${color}`} />
<p className="text-muted-foreground">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}
};
const previewState = useFilePreview({ file, isOpen });
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -331,12 +35,24 @@ export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProp
<span className="truncate">{file.name}</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">{renderPreview()}</div>
<div className="flex-1 overflow-auto">
<FilePreviewRenderer
fileType={previewState.fileType}
fileName={file.name}
previewUrl={previewState.previewUrl}
videoBlob={previewState.videoBlob}
textContent={previewState.textContent}
isLoading={previewState.isLoading}
pdfAsBlob={previewState.pdfAsBlob}
pdfLoadFailed={previewState.pdfLoadFailed}
onPdfLoadError={previewState.handlePdfLoadError}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={handleDownload}>
<Button onClick={previewState.handleDownload}>
<IconDownload className="h-4 w-4" />
{t("common.download")}
</Button>

View File

@@ -0,0 +1,13 @@
import { CustomAudioPlayer } from "@/components/audio/custom-audio-player";
interface AudioPreviewProps {
src: string;
}
export function AudioPreview({ src }: AudioPreviewProps) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-4">
<CustomAudioPlayer src={src} />
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useTranslations } from "next-intl";
import { getFileIcon } from "@/utils/file-icons";
interface DefaultPreviewProps {
fileName: string;
isLoading?: boolean;
message?: string;
}
export function DefaultPreview({ fileName, isLoading, message }: DefaultPreviewProps) {
const t = useTranslations();
const { icon: FileIcon, color } = getFileIcon(fileName);
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loading")}</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`h-12 w-12 ${color}`} />
<p className="text-muted-foreground">{message || t("filePreview.notAvailable")}</p>
<p className="text-sm text-muted-foreground">{t("filePreview.downloadToView")}</p>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { type FileType } from "@/utils/file-types";
import { AudioPreview } from "./audio-preview";
import { DefaultPreview } from "./default-preview";
import { ImagePreview } from "./image-preview";
import { PdfPreview } from "./pdf-preview";
import { TextPreview } from "./text-preview";
import { VideoPreview } from "./video-preview";
interface FilePreviewRendererProps {
fileType: FileType;
fileName: string;
previewUrl: string | null;
videoBlob: string | null;
textContent: string | null;
isLoading: boolean;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
onPdfLoadError: () => void;
}
export function FilePreviewRenderer({
fileType,
fileName,
previewUrl,
videoBlob,
textContent,
isLoading,
pdfAsBlob,
pdfLoadFailed,
onPdfLoadError,
}: FilePreviewRendererProps) {
if (isLoading) {
return <DefaultPreview fileName={fileName} isLoading />;
}
const mediaUrl = fileType === "video" ? videoBlob : previewUrl;
if (!mediaUrl && (fileType === "video" || fileType === "audio")) {
return <DefaultPreview fileName={fileName} />;
}
if (fileType === "text" && !textContent) {
return <DefaultPreview fileName={fileName} />;
}
if (!previewUrl && fileType !== "video" && fileType !== "text") {
return <DefaultPreview fileName={fileName} />;
}
switch (fileType) {
case "pdf":
return (
<PdfPreview
src={previewUrl!}
fileName={fileName}
pdfAsBlob={pdfAsBlob}
pdfLoadFailed={pdfLoadFailed}
onLoadError={onPdfLoadError}
/>
);
case "text":
return <TextPreview content={textContent} fileName={fileName} />;
case "image":
return <ImagePreview src={previewUrl!} alt={fileName} />;
case "audio":
return <AudioPreview src={mediaUrl!} />;
case "video":
return <VideoPreview src={mediaUrl!} />;
default:
return <DefaultPreview fileName={fileName} />;
}
}

View File

@@ -0,0 +1,14 @@
import { AspectRatio } from "@/components/ui/aspect-ratio";
interface ImagePreviewProps {
src: string;
alt: string;
}
export function ImagePreview({ src, alt }: ImagePreviewProps) {
return (
<AspectRatio ratio={16 / 9} className="bg-muted">
<img src={src} alt={alt} className="object-contain w-full h-full rounded-md" />
</AspectRatio>
);
}

View File

@@ -0,0 +1,7 @@
export { ImagePreview } from "./image-preview";
export { VideoPreview } from "./video-preview";
export { AudioPreview } from "./audio-preview";
export { PdfPreview } from "./pdf-preview";
export { TextPreview } from "./text-preview";
export { DefaultPreview } from "./default-preview";
export { FilePreviewRenderer } from "./file-preview-render";

View File

@@ -0,0 +1,54 @@
import { useTranslations } from "next-intl";
import { ScrollArea } from "@/components/ui/scroll-area";
interface PdfPreviewProps {
src: string;
fileName: string;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
onLoadError: () => void;
}
export function PdfPreview({ src, fileName, pdfAsBlob, pdfLoadFailed, onLoadError }: PdfPreviewProps) {
const t = useTranslations();
return (
<ScrollArea className="w-full">
<div className="w-full min-h-[600px] border rounded-lg overflow-hidden bg-card">
{pdfAsBlob ? (
<iframe
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={fileName}
style={{ border: "none" }}
/>
) : pdfLoadFailed ? (
<div className="flex items-center justify-center h-full min-h-[600px]">
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-muted-foreground">{t("filePreview.loadingAlternative")}</p>
</div>
</div>
) : (
<div className="w-full h-full min-h-[600px] relative">
<object
data={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
type="application/pdf"
className="w-full h-full min-h-[600px]"
onError={onLoadError}
>
<iframe
src={`${src}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`}
className="w-full h-full min-h-[600px]"
title={fileName}
style={{ border: "none" }}
onError={onLoadError}
/>
</object>
</div>
)}
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,40 @@
import { useTranslations } from "next-intl";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getFileExtension } from "@/utils/file-types";
interface TextPreviewProps {
content: string | null;
fileName: string;
isLoading?: boolean;
}
export function TextPreview({ content, fileName, isLoading }: TextPreviewProps) {
const t = useTranslations();
const extension = getFileExtension(fileName);
if (isLoading || !content) {
return (
<ScrollArea className="w-full max-h-[600px]">
<div className="w-full border rounded-lg overflow-hidden bg-card">
<div className="flex items-center justify-center h-32">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
<p className="text-sm text-muted-foreground">{t("filePreview.loading")}</p>
</div>
</div>
</div>
</ScrollArea>
);
}
return (
<ScrollArea className="w-full max-h-[600px]">
<div className="w-full border rounded-lg overflow-hidden bg-card">
<pre className="p-4 text-sm font-mono whitespace-pre-wrap break-words overflow-x-auto">
<code className={`language-${extension || "text"}`}>{content}</code>
</pre>
</div>
</ScrollArea>
);
}

View File

@@ -0,0 +1,20 @@
import { useTranslations } from "next-intl";
interface VideoPreviewProps {
src: string;
}
export function VideoPreview({ src }: VideoPreviewProps) {
const t = useTranslations();
return (
<div className="flex flex-col items-center justify-center gap-4 py-6">
<div className="w-full max-w-4xl">
<video controls className="w-full rounded-lg" preload="metadata" style={{ maxHeight: "70vh" }}>
<source src={src} />
{t("filePreview.videoNotSupported")}
</video>
</div>
</div>
);
}

View File

@@ -1,18 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
IconAlertTriangle,
IconCheck,
IconCloudUpload,
IconFileText,
IconFileTypePdf,
IconFileTypography,
IconLoader,
IconPhoto,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { IconAlertTriangle, IconCheck, IconCloudUpload, IconLoader, IconTrash, IconX } from "@tabler/icons-react";
import axios from "axios";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
@@ -21,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { checkFile, getPresignedUrl, registerFile } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons";
import { generateSafeFileName } from "@/utils/file-utils";
import getErrorData from "@/utils/getErrorData";
@@ -157,11 +147,9 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
handleFilesSelect(event.dataTransfer.files);
};
const getFileIcon = (fileType: string) => {
if (fileType.startsWith("image/")) return <IconPhoto size={24} className="text-blue-500" />;
if (fileType.includes("pdf")) return <IconFileTypePdf size={24} className="text-red-500" />;
if (fileType.includes("word")) return <IconFileTypography size={24} className="text-blue-700" />;
return <IconFileText size={24} className="text-muted-foreground" />;
const renderFileIcon = (fileName: string) => {
const { icon: FileIcon, color } = getFileIcon(fileName);
return <FileIcon size={24} className={color} />;
};
const getStatusIcon = (status: UploadStatus) => {
@@ -420,7 +408,7 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
className="w-10 h-10 rounded object-cover"
/>
) : (
getFileIcon(upload.file.type)
renderFileIcon(upload.file.name)
)}
</div>
@@ -453,7 +441,33 @@ export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalP
>
<IconX size={14} />
</Button>
) : upload.status === UploadStatus.SUCCESS ? null : (
) : upload.status === UploadStatus.SUCCESS ? null : upload.status === UploadStatus.ERROR ? (
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setFileUploads((prev) =>
prev.map((u) =>
u.id === upload.id ? { ...u, status: UploadStatus.PENDING, error: undefined } : u
)
);
}}
className="h-8 w-8 p-0"
title={t("uploadFile.retry")}
>
<IconLoader size={14} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeFile(upload.id)}
className="h-8 w-8 p-0"
>
<IconTrash size={14} />
</Button>
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => removeFile(upload.id)} className="h-8 w-8 p-0">
<IconTrash size={14} />
</Button>

View File

@@ -41,6 +41,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const checkAuth = async () => {
try {
const appInfoResponse = await fetch("/api/app/info");
const appInfo = await appInfoResponse.json();
if (appInfo.firstUserAccess) {
setUser(null);
setIsAdmin(false);
setIsAuthenticated(false);
return;
}
const response = await getCurrentUser();
if (!response?.data?.user) {
throw new Error("No user data");

View File

@@ -0,0 +1,255 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { getDownloadUrl } from "@/http/endpoints";
import { getFileExtension, getFileType, type FileType } from "@/utils/file-types";
interface FilePreviewState {
previewUrl: string | null;
videoBlob: string | null;
textContent: string | null;
downloadUrl: string | null;
isLoading: boolean;
isLoadingPreview: boolean;
pdfAsBlob: boolean;
pdfLoadFailed: boolean;
}
interface UseFilePreviewProps {
file: {
name: string;
objectName: string;
type?: string;
};
isOpen: boolean;
}
export function useFilePreview({ file, isOpen }: UseFilePreviewProps) {
const t = useTranslations();
const [state, setState] = useState<FilePreviewState>({
previewUrl: null,
videoBlob: null,
textContent: null,
downloadUrl: null,
isLoading: true,
isLoadingPreview: false,
pdfAsBlob: false,
pdfLoadFailed: false,
});
const fileType: FileType = getFileType(file.name);
// Reset state when file changes or modal opens
useEffect(() => {
if (isOpen && file.objectName && !state.isLoadingPreview) {
resetState();
loadPreview();
}
}, [file.objectName, isOpen]);
// Cleanup blob URLs
useEffect(() => {
return () => {
cleanupBlobUrls();
};
}, [state.previewUrl, state.videoBlob]);
// Cleanup when modal closes
useEffect(() => {
if (!isOpen) {
cleanupBlobUrls();
}
}, [isOpen]);
const resetState = () => {
setState((prev) => ({
...prev,
previewUrl: null,
videoBlob: null,
textContent: null,
downloadUrl: null,
pdfAsBlob: false,
pdfLoadFailed: false,
isLoading: true,
}));
};
const cleanupBlobUrls = () => {
if (state.previewUrl && state.previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(state.previewUrl);
}
if (state.videoBlob && state.videoBlob.startsWith("blob:")) {
URL.revokeObjectURL(state.videoBlob);
}
};
const loadPreview = async () => {
if (!file.objectName || state.isLoadingPreview) return;
setState((prev) => ({ ...prev, isLoadingPreview: true }));
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const url = response.data.url;
setState((prev) => ({ ...prev, downloadUrl: url }));
switch (fileType) {
case "video":
await loadVideoPreview(url);
break;
case "audio":
await loadAudioPreview(url);
break;
case "pdf":
await loadPdfPreview(url);
break;
case "text":
await loadTextPreview(url);
break;
default:
setState((prev) => ({ ...prev, previewUrl: url }));
}
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setState((prev) => ({
...prev,
isLoading: false,
isLoadingPreview: false,
}));
}
};
const loadVideoPreview = 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();
const blobUrl = URL.createObjectURL(blob);
setState((prev) => ({ ...prev, videoBlob: blobUrl }));
} catch (error) {
console.error("Failed to load video as blob:", error);
setState((prev) => ({ ...prev, previewUrl: url }));
}
};
const loadAudioPreview = 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();
const blobUrl = URL.createObjectURL(blob);
setState((prev) => ({ ...prev, previewUrl: blobUrl }));
} catch (error) {
console.error("Failed to load audio as blob:", error);
setState((prev) => ({ ...prev, previewUrl: url }));
}
};
const loadPdfPreview = 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();
const finalBlob = new Blob([blob], { type: "application/pdf" });
const blobUrl = URL.createObjectURL(finalBlob);
setState((prev) => ({
...prev,
previewUrl: blobUrl,
pdfAsBlob: true,
}));
} catch (error) {
console.error("Failed to load PDF as blob:", error);
setState((prev) => ({ ...prev, previewUrl: url }));
setTimeout(() => {
if (!state.pdfLoadFailed && !state.pdfAsBlob) {
handlePdfLoadError();
}
}, 4000);
}
};
const loadTextPreview = async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const text = await response.text();
const extension = getFileExtension(file.name);
try {
// For JSON files, validate and format
if (extension === "json") {
const parsed = JSON.parse(text);
const formatted = JSON.stringify(parsed, null, 2);
setState((prev) => ({ ...prev, textContent: formatted }));
} else {
// For other text files, show as-is
setState((prev) => ({ ...prev, textContent: text }));
}
} catch (jsonError) {
// If JSON parsing fails, show as plain text
setState((prev) => ({ ...prev, textContent: text }));
}
} catch (error) {
console.error("Failed to load text content:", error);
setState((prev) => ({ ...prev, textContent: null }));
}
};
const handlePdfLoadError = async () => {
if (state.pdfLoadFailed || state.pdfAsBlob) return;
setState((prev) => ({ ...prev, pdfLoadFailed: true }));
if (state.downloadUrl) {
setTimeout(() => {
loadPdfPreview(state.downloadUrl!);
}, 500);
}
};
const handleDownload = async () => {
try {
let downloadUrlToUse = state.downloadUrl;
if (!downloadUrlToUse) {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
downloadUrlToUse = response.data.url;
}
const link = document.createElement("a");
link.href = downloadUrlToUse;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
toast.error(t("filePreview.downloadError"));
console.error("Download error:", error);
}
};
return {
...state,
fileType,
handleDownload,
handlePdfLoadError,
};
}

View File

@@ -1,6 +1,5 @@
import type { AxiosResponse } from "axios";
// Create Reverse Share
export type CreateReverseShareBody = {
name?: string;
description?: string;
@@ -31,7 +30,6 @@ export type CreateReverseShareResult = AxiosResponse<{
};
}>;
// Update Reverse Share
export type UpdateReverseShareBody = {
id: string;
name?: string;
@@ -64,7 +62,6 @@ export type UpdateReverseShareResult = AxiosResponse<{
};
}>;
// List User Reverse Shares
export type ListUserReverseSharesResult = AxiosResponse<{
reverseShares: {
id: string;
@@ -91,7 +88,6 @@ export type ListUserReverseSharesResult = AxiosResponse<{
}[];
}>;
// Get Reverse Share
export type GetReverseShareResult = AxiosResponse<{
reverseShare: {
id: string;
@@ -118,7 +114,6 @@ export type GetReverseShareResult = AxiosResponse<{
};
}>;
// Delete Reverse Share
export type DeleteReverseShareResult = AxiosResponse<{
reverseShare: {
id: string;
@@ -138,7 +133,6 @@ export type DeleteReverseShareResult = AxiosResponse<{
};
}>;
// Get Reverse Share for Upload (Public)
export type GetReverseShareForUploadParams = {
password?: string;
};
@@ -157,7 +151,6 @@ export type GetReverseShareForUploadResult = AxiosResponse<{
};
}>;
// Update Password
export type UpdateReverseSharePasswordBody = {
password: string | null;
};
@@ -181,7 +174,6 @@ export type UpdateReverseSharePasswordResult = AxiosResponse<{
};
}>;
// Presigned URL
export type GetPresignedUrlBody = {
objectName: string;
};
@@ -191,7 +183,6 @@ export type GetPresignedUrlResult = AxiosResponse<{
expiresIn: number;
}>;
// Register File Upload
export type RegisterFileUploadBody = {
name: string;
description?: string;
@@ -210,7 +201,6 @@ export type RegisterFileUploadResult = AxiosResponse<{
file: ReverseShareFile;
}>;
// Check Password
export type CheckReverseSharePasswordBody = {
password: string;
};
@@ -219,18 +209,15 @@ export type CheckReverseSharePasswordResult = AxiosResponse<{
valid: boolean;
}>;
// Download File
export type DownloadReverseShareFileResult = AxiosResponse<{
url: string;
expiresIn: number;
}>;
// Delete File
export type DeleteReverseShareFileResult = AxiosResponse<{
file: ReverseShareFile;
}>;
// Shared Type
export type ReverseShareFile = {
id: string;
name: string;
@@ -244,7 +231,6 @@ export type ReverseShareFile = {
updatedAt: string;
};
// Activate Reverse Share
export type ActivateReverseShareResult = AxiosResponse<{
reverseShare: {
id: string;
@@ -264,7 +250,6 @@ export type ActivateReverseShareResult = AxiosResponse<{
};
}>;
// Deactivate Reverse Share
export type DeactivateReverseShareResult = AxiosResponse<{
reverseShare: {
id: string;
@@ -284,7 +269,6 @@ export type DeactivateReverseShareResult = AxiosResponse<{
};
}>;
// Update Reverse Share File
export type UpdateReverseShareFileBody = {
name?: string;
description?: string | null;

View File

@@ -7,8 +7,8 @@
*/
export type LoginBody = {
/** User email */
email: string;
/** User email or username */
emailOrUsername: string;
/**
* User password
* @minLength 8

View File

@@ -1,5 +1,30 @@
import {
Icon,
IconApi,
IconAtom,
IconBook,
IconBrandCss3,
IconBrandDocker,
IconBrandGit,
IconBrandGolang,
IconBrandHtml5,
IconBrandJavascript,
IconBrandKotlin,
IconBrandNpm,
IconBrandPhp,
IconBrandPython,
IconBrandReact,
IconBrandRust,
IconBrandSass,
IconBrandSwift,
IconBrandTypescript,
IconBrandVue,
IconBrandYarn,
IconBug,
IconCloud,
IconCode,
IconDatabase,
IconDeviceDesktop,
IconFile,
IconFileCode,
IconFileDescription,
@@ -8,8 +33,16 @@ import {
IconFileText,
IconFileTypePdf,
IconFileZip,
IconKey,
IconLock,
IconMarkdown,
IconMath,
IconPalette,
IconPhoto,
IconPresentation,
IconSettings,
IconTerminal,
IconTool,
IconVideo,
} from "@tabler/icons-react";
@@ -20,56 +53,452 @@ interface FileIconMapping {
}
const fileIcons: FileIconMapping[] = [
// Images
{
extensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"],
extensions: ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff", "ico", "heic", "avif"],
icon: IconPhoto,
color: "text-blue-500",
},
// Documents
{
extensions: ["pdf"],
icon: IconFileTypePdf,
color: "text-red-500",
},
{
extensions: ["doc", "docx"],
extensions: ["doc", "docx", "odt", "rtf"],
icon: IconFileText,
color: "text-blue-600",
},
{
extensions: ["xls", "xlsx", "csv"],
extensions: ["xls", "xlsx", "ods", "csv"],
icon: IconFileSpreadsheet,
color: "text-green-600",
},
{
extensions: ["ppt", "pptx"],
extensions: ["ppt", "pptx", "odp"],
icon: IconPresentation,
color: "text-orange-500",
},
// Media
{
extensions: ["mp3", "wav", "ogg", "m4a"],
extensions: ["mp3", "wav", "ogg", "m4a", "aac", "flac", "wma", "opus"],
icon: IconFileMusic,
color: "text-purple-500",
},
{
extensions: ["mp4", "avi", "mov", "wmv", "mkv"],
extensions: ["mp4", "avi", "mov", "wmv", "mkv", "webm", "flv", "m4v", "3gp"],
icon: IconVideo,
color: "text-pink-500",
},
// Archives
{
extensions: ["zip", "rar", "7z", "tar", "gz"],
extensions: ["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "lz", "cab", "deb", "rpm"],
icon: IconFileZip,
color: "text-yellow-600",
},
// JavaScript/TypeScript
{
extensions: ["html", "css", "js", "ts", "jsx", "tsx", "json", "xml"],
icon: IconFileCode,
extensions: ["js", "mjs", "cjs"],
icon: IconBrandJavascript,
color: "text-yellow-500",
},
{
extensions: ["ts", "tsx"],
icon: IconBrandTypescript,
color: "text-blue-600",
},
{
extensions: ["jsx"],
icon: IconBrandReact,
color: "text-cyan-500",
},
{
extensions: ["vue"],
icon: IconBrandVue,
color: "text-green-500",
},
// Web Technologies
{
extensions: ["html", "htm", "xhtml"],
icon: IconBrandHtml5,
color: "text-orange-600",
},
{
extensions: ["css"],
icon: IconBrandCss3,
color: "text-blue-600",
},
{
extensions: ["scss", "sass"],
icon: IconBrandSass,
color: "text-pink-600",
},
{
extensions: ["less", "stylus"],
icon: IconPalette,
color: "text-purple-600",
},
// Programming Languages
{
extensions: ["py", "pyw", "pyc", "pyo", "pyd"],
icon: IconBrandPython,
color: "text-yellow-600",
},
{
extensions: ["php", "phtml"],
icon: IconBrandPhp,
color: "text-purple-700",
},
{
extensions: ["go"],
icon: IconBrandGolang,
color: "text-cyan-600",
},
{
extensions: ["rs"],
icon: IconBrandRust,
color: "text-orange-700",
},
{
extensions: ["swift"],
icon: IconBrandSwift,
color: "text-orange-500",
},
{
extensions: ["kt", "kts"],
icon: IconBrandKotlin,
color: "text-purple-600",
},
{
extensions: ["java", "class", "jar"],
icon: IconCode,
color: "text-red-600",
},
{
extensions: ["c", "h"],
icon: IconCode,
color: "text-blue-700",
},
{
extensions: ["cpp", "cxx", "cc", "hpp", "hxx"],
icon: IconCode,
color: "text-blue-800",
},
{
extensions: ["cs"],
icon: IconCode,
color: "text-purple-700",
},
{
extensions: ["rb", "rbw", "rake"],
icon: IconCode,
color: "text-red-500",
},
{
extensions: ["scala", "sc"],
icon: IconCode,
color: "text-red-700",
},
{
extensions: ["clj", "cljs", "cljc", "edn"],
icon: IconCode,
color: "text-green-700",
},
{
extensions: ["hs", "lhs"],
icon: IconCode,
color: "text-purple-800",
},
{
extensions: ["elm"],
icon: IconCode,
color: "text-blue-700",
},
{
extensions: ["dart"],
icon: IconCode,
color: "text-blue-600",
},
{
extensions: ["lua"],
icon: IconCode,
color: "text-blue-800",
},
{
extensions: ["r", "rmd"],
icon: IconMath,
color: "text-blue-700",
},
{
extensions: ["matlab", "m"],
icon: IconMath,
color: "text-orange-600",
},
{
extensions: ["julia", "jl"],
icon: IconMath,
color: "text-purple-600",
},
// Shell Scripts
{
extensions: ["sh", "bash", "zsh", "fish"],
icon: IconTerminal,
color: "text-green-600",
},
{
extensions: ["ps1", "psm1", "psd1"],
icon: IconTerminal,
color: "text-blue-700",
},
{
extensions: ["bat", "cmd"],
icon: IconTerminal,
color: "text-gray-600",
},
// Database
{
extensions: ["sql", "mysql", "pgsql", "sqlite", "db"],
icon: IconDatabase,
color: "text-blue-700",
},
// Configuration Files
{
extensions: ["json", "json5"],
icon: IconCode,
color: "text-yellow-700",
},
{
extensions: ["yaml", "yml"],
icon: IconSettings,
color: "text-purple-600",
},
{
extensions: ["toml"],
icon: IconSettings,
color: "text-orange-600",
},
{
extensions: ["xml", "xsd", "xsl", "xslt"],
icon: IconCode,
color: "text-orange-700",
},
{
extensions: ["ini", "cfg", "conf", "config"],
icon: IconSettings,
color: "text-gray-600",
},
{
extensions: ["txt", "md", "rtf"],
extensions: ["env", "dotenv"],
icon: IconKey,
color: "text-green-700",
},
{
extensions: ["properties"],
icon: IconSettings,
color: "text-blue-600",
},
// Docker & DevOps
{
extensions: ["dockerfile", "containerfile"],
icon: IconBrandDocker,
color: "text-blue-600",
},
{
extensions: ["tf", "tfvars", "hcl"],
icon: IconCloud,
color: "text-purple-600",
},
{
extensions: ["k8s", "kubernetes"],
icon: IconCloud,
color: "text-blue-700",
},
{
extensions: ["ansible", "playbook"],
icon: IconTool,
color: "text-red-600",
},
// Package Managers
{
extensions: ["package"],
icon: IconBrandNpm,
color: "text-red-600",
},
{
extensions: ["yarn"],
icon: IconBrandYarn,
color: "text-blue-600",
},
{
extensions: ["cargo"],
icon: IconBrandRust,
color: "text-orange-700",
},
{
extensions: ["gemfile"],
icon: IconCode,
color: "text-red-500",
},
{
extensions: ["composer"],
icon: IconBrandPhp,
color: "text-purple-700",
},
{
extensions: ["requirements", "pipfile", "poetry"],
icon: IconBrandPython,
color: "text-yellow-600",
},
{
extensions: ["gradle", "build.gradle"],
icon: IconTool,
color: "text-green-700",
},
{
extensions: ["pom"],
icon: IconCode,
color: "text-orange-600",
},
{
extensions: ["makefile", "cmake"],
icon: IconTool,
color: "text-blue-700",
},
// Git
{
extensions: ["gitignore", "gitattributes", "gitmodules", "gitconfig"],
icon: IconBrandGit,
color: "text-orange-600",
},
// Documentation
{
extensions: ["md", "markdown"],
icon: IconMarkdown,
color: "text-gray-700",
},
{
extensions: ["rst", "txt"],
icon: IconFileDescription,
color: "text-gray-500",
},
{
extensions: ["adoc", "asciidoc"],
icon: IconBook,
color: "text-blue-600",
},
{
extensions: ["tex", "latex"],
icon: IconMath,
color: "text-green-700",
},
{
extensions: ["log"],
icon: IconBug,
color: "text-yellow-600",
},
// Templates
{
extensions: ["hbs", "handlebars", "mustache"],
icon: IconCode,
color: "text-orange-600",
},
{
extensions: ["twig"],
icon: IconCode,
color: "text-green-600",
},
{
extensions: ["liquid"],
icon: IconCode,
color: "text-blue-600",
},
{
extensions: ["ejs", "pug", "jade"],
icon: IconCode,
color: "text-brown-600",
},
// Data Formats
{
extensions: ["graphql", "gql"],
icon: IconApi,
color: "text-pink-600",
},
{
extensions: ["proto", "protobuf"],
icon: IconApi,
color: "text-blue-700",
},
// Security & Certificates
{
extensions: ["pem", "crt", "cer", "key", "p12", "pfx"],
icon: IconLock,
color: "text-green-800",
},
// Web Assembly
{
extensions: ["wasm", "wat"],
icon: IconAtom,
color: "text-purple-700",
},
// Shaders
{
extensions: ["glsl", "hlsl", "vert", "frag", "geom"],
icon: IconDeviceDesktop,
color: "text-cyan-700",
},
// Specialized
{
extensions: ["vim", "vimrc"],
icon: IconCode,
color: "text-green-800",
},
{
extensions: ["eslintrc", "prettierrc", "babelrc"],
icon: IconSettings,
color: "text-yellow-700",
},
{
extensions: ["tsconfig", "jsconfig"],
icon: IconSettings,
color: "text-blue-700",
},
{
extensions: ["webpack", "rollup", "vite"],
icon: IconTool,
color: "text-cyan-600",
},
{
extensions: ["lock", "sum"],
icon: IconLock,
color: "text-gray-600",
},
// Fallback for general text/code files
{
extensions: ["svelte", "astro", "erb", "haml", "slim"],
icon: IconFileCode,
color: "text-gray-600",
},
];
export function getFileIcon(filename: string): { icon: Icon; color: string } {

View File

@@ -0,0 +1,374 @@
export type FileType = "pdf" | "image" | "audio" | "video" | "text" | "other";
export function getFileType(fileName: string): FileType {
const extension = fileName.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"].includes(extension || "")) {
return "image";
}
if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(extension || "")) {
return "audio";
}
if (["mp4", "webm", "ogg", "mov", "avi", "mkv", "wmv", "flv", "m4v"].includes(extension || "")) {
return "video";
}
const textExtensions = [
// Data formats
"json",
"json5",
"jsonp",
"txt",
"csv",
"xml",
"svg",
"toml",
"yaml",
"yml",
"ini",
"conf",
"config",
"env",
"properties",
// Documentation
"md",
"markdown",
"adoc",
"asciidoc",
"rst",
"textile",
"wiki",
"log",
// Web technologies
"html",
"htm",
"xhtml",
"css",
"scss",
"sass",
"less",
"stylus",
// JavaScript ecosystem
"js",
"jsx",
"ts",
"tsx",
"mjs",
"cjs",
"vue",
"svelte",
"coffee",
"coffeescript",
// Programming languages
"php",
"py",
"pyw",
"rb",
"java",
"kt",
"kts",
"scala",
"clj",
"cljs",
"cljc",
"hs",
"elm",
"f#",
"fs",
"fsx",
"vb",
"vba",
"c",
"cpp",
"cxx",
"cc",
"h",
"hpp",
"hxx",
"cs",
"go",
"rs",
"swift",
"dart",
"r",
"rmd",
"pl",
"pm",
// Shell scripts
"sh",
"bash",
"zsh",
"fish",
"ps1",
"bat",
"cmd",
// Database
"sql",
"plsql",
"psql",
"mysql",
"sqlite",
// Configuration files
"dockerfile",
"containerfile",
"gitignore",
"gitattributes",
"gitmodules",
"gitconfig",
"editorconfig",
"eslintrc",
"prettierrc",
"stylelintrc",
"babelrc",
"browserslistrc",
"tsconfig",
"jsconfig",
"webpack",
"rollup",
"vite",
"astro",
// Package managers
"package",
"composer",
"gemfile",
"podfile",
"pipfile",
"poetry",
"pyproject",
"requirements",
"cargo",
"go.mod",
"go.sum",
"sbt",
"build.gradle",
"build.sbt",
"pom",
"build",
// Build tools
"makefile",
"cmake",
"rakefile",
"gradle",
"gulpfile",
"gruntfile",
"justfile",
// Templates
"hbs",
"handlebars",
"mustache",
"twig",
"jinja",
"jinja2",
"liquid",
"ejs",
"pug",
"jade",
// Data serialization
"proto",
"protobuf",
"avro",
"thrift",
"graphql",
"gql",
// Markup & styling
"tex",
"latex",
"bibtex",
"rtf",
"org",
"pod",
// Specialized formats
"vim",
"vimrc",
"tmux",
"nginx",
"apache",
"htaccess",
"robots",
"sitemap",
"webmanifest",
"lock",
"sum",
"mod",
"workspace",
"solution",
"sln",
"csproj",
"vcxproj",
"xcodeproj",
// Additional programming languages
"lua",
"rb",
"php",
"asp",
"aspx",
"jsp",
"erb",
"haml",
"slim",
"perl",
"awk",
"sed",
"tcl",
"groovy",
"scala",
"rust",
"zig",
"nim",
"crystal",
"julia",
"matlab",
"octave",
"wolfram",
"mathematica",
"sage",
"maxima",
"fortran",
"cobol",
"ada",
"pascal",
"delphi",
"basic",
"vb6",
"assembly",
"asm",
"s",
"nasm",
"gas",
"lisp",
"scheme",
"racket",
"clojure",
"erlang",
"elixir",
"haskell",
"ocaml",
"fsharp",
"prolog",
"mercury",
"curry",
"clean",
"idris",
"agda",
"coq",
"lean",
"smalltalk",
"forth",
"factor",
"postscript",
"tcl",
"tk",
"expect",
"applescript",
"powershell",
"autohotkey",
"ahk",
"autoit",
"nsis",
// Web assembly and low level
"wasm",
"wat",
"wast",
"wit",
"wai",
// Shaders
"glsl",
"hlsl",
"cg",
"fx",
"fxh",
"vsh",
"fsh",
"vert",
"frag",
"geom",
"tesc",
"tese",
"comp",
// Game development
"gdscript",
"gd",
"cs",
"boo",
"unityscript",
"mel",
"maxscript",
"haxe",
"as",
"actionscript",
// DevOps & Infrastructure
"tf",
"tfvars",
"hcl",
"nomad",
"consul",
"vault",
"packer",
"ansible",
"puppet",
"chef",
"salt",
"k8s",
"kubernetes",
"helm",
"kustomize",
"skaffold",
"tilt",
"buildkite",
"circleci",
"travis",
"jenkins",
"github",
"gitlab",
"bitbucket",
"azure",
"aws",
"gcp",
"terraform",
"cloudformation",
// Documentation generators
"jsdoc",
"javadoc",
"godoc",
"rustdoc",
"sphinx",
"mkdocs",
"gitbook",
"jekyll",
"hugo",
"gatsby",
];
if (textExtensions.includes(extension || "")) {
return "text";
}
return "other";
}
export function getFileExtension(fileName: string): string {
return fileName.split(".").pop()?.toLowerCase() || "";
}

View File

@@ -36,46 +36,86 @@ echo "💾 Database: $DATABASE_URL"
echo "📁 Creating data directories..."
mkdir -p /app/server/prisma /app/server/uploads /app/server/temp-chunks /app/server/uploads/logo
# Fix ownership of database directory BEFORE database operations
if [ "$(id -u)" = "0" ]; then
echo "🔐 Ensuring proper ownership before database operations..."
chown -R $TARGET_UID:$TARGET_GID /app/server/prisma 2>/dev/null || true
fi
# Check if it's a first run (no database file exists)
if [ ! -f "/app/server/prisma/palmr.db" ]; then
echo "🚀 First run detected - setting up database..."
# Create database with proper schema path
# Create database with proper schema path - run as target user to avoid permission issues
echo "🗄️ Creating database schema..."
npx prisma db push --schema=./prisma/schema.prisma --skip-generate
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID npx prisma db push --schema=./prisma/schema.prisma --skip-generate
else
npx prisma db push --schema=./prisma/schema.prisma --skip-generate
fi
# Run seed script from application directory (where node_modules is)
# Run seed script from application directory (where node_modules is) - as target user
echo "🌱 Seeding database..."
node ./prisma/seed.js
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
else
node ./prisma/seed.js
fi
echo "✅ Database setup completed!"
else
echo "♻️ Existing database found"
# Always run migrations to ensure schema is up to date
# Always run migrations to ensure schema is up to date - as target user
echo "🔧 Checking for schema updates..."
npx prisma db push --schema=./prisma/schema.prisma --skip-generate
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID npx prisma db push --schema=./prisma/schema.prisma --skip-generate
else
npx prisma db push --schema=./prisma/schema.prisma --skip-generate
fi
# Check if configurations exist
# Check if configurations exist - as target user
echo "🔍 Verifying database configurations..."
CONFIG_COUNT=$(node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
prisma.appConfig.count()
.then(count => {
console.log(count);
process.exit(0);
})
.catch(() => {
console.log(0);
process.exit(0);
});
" 2>/dev/null || echo "0")
CONFIG_COUNT=$(
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
prisma.appConfig.count()
.then(count => {
console.log(count);
process.exit(0);
})
.catch(() => {
console.log(0);
process.exit(0);
});
" 2>/dev/null || echo "0"
else
node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
prisma.appConfig.count()
.then(count => {
console.log(count);
process.exit(0);
})
.catch(() => {
console.log(0);
process.exit(0);
});
" 2>/dev/null || echo "0"
fi
)
if [ "$CONFIG_COUNT" -eq "0" ]; then
echo "🌱 No configurations found, running seed..."
# Always run seed from application directory where node_modules is available
node ./prisma/seed.js
# Always run seed from application directory where node_modules is available - as target user
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
else
node ./prisma/seed.js
fi
else
echo "✅ Found $CONFIG_COUNT configurations"
fi