Compare commits

...

14 Commits

Author SHA1 Message Date
Daniel Luiz Alves
fd28445680 chore: standardize package version format to remove 'v' prefix (#154) 2025-07-15 16:59:13 -03:00
Daniel Luiz Alves
19b7448c3a chore: standardize package version format to remove 'v' prefix in docs, server, and web applications 2025-07-15 16:51:52 -03:00
Daniel Luiz Alves
53c39135af fix: fix suspense fallback (#153) 2025-07-15 16:40:41 -03:00
Daniel Luiz Alves
b9147038e6 style(demo): add empty line after "use client" directive 2025-07-15 16:39:37 -03:00
Daniel Luiz Alves
9a0b7f5c55 refactor(demo): extract demo logic into separate component for better maintainability 2025-07-15 16:37:35 -03:00
Daniel Luiz Alves
2a5f9f03ae v3.1.2-beta (#152) 2025-07-15 15:50:28 -03:00
Daniel Luiz Alves
78f6e36fc9 chore: update package versions to v3.1.2-beta for docs, server, and web applications 2025-07-15 15:22:12 -03:00
Daniel Luiz Alves
8e7aadd183 docs(demo): implement live demo functionality and demo page
- Added a Live Demo button to the home page that generates a unique demo ID and token, storing them in session storage.
- Created a new DemoPage component to validate access using the demo ID and token, and to manage demo creation and status checking.
- Introduced BackgroundLights component for visual effects on the demo page.
- Enhanced user experience with loading states and error handling during demo generation.
2025-07-15 15:21:00 -03:00
Daniel Luiz Alves
794a2782ac feat(demo): add DEMO_MODE environment variable and storage limits
- Introduced a new DEMO_MODE environment variable to toggle demo functionality.
- Updated FileController and ReverseShareService to limit user storage to 200MB when DEMO_MODE is enabled.
- Enhanced StorageService to reflect demo storage limits in disk space calculations.
- Added missing authentication providers in the settings form and server start script for better provider management.
2025-07-15 13:45:46 -03:00
Daniel Luiz Alves
383f26e777 Merge branch 'next' of github.com:kyantech/Palmr into next 2025-07-14 17:25:53 -03:00
Daniel Luiz Alves
2db88d3902 chore: add DISABLE_FILESYSTEM_ENCRYPTION option
- Enhanced comments in docker-compose files to clarify the purpose of environment variables, including optional settings for UID, GID, and filesystem encryption.
- Introduced DISABLE_FILESYSTEM_ENCRYPTION variable to allow users to disable file encryption, making the ENCRYPTION_KEY optional.
- Updated documentation in quick-start guide to reflect changes in environment variable usage and security warnings.
2025-07-14 17:25:27 -03:00
Daniel Luiz Alves
5e96633a1e Fix docker-compose.yaml (#147) 2025-07-11 12:34:24 -03:00
Daniel Luiz Alves
6c80ad8b2a Fix docker-compose.yaml (#146) 2025-07-11 12:33:09 -03:00
GeorgH93
96bd39eb25 Fix docker-compose.yaml
Move PALMR_UID, PALMR_GID and SECURE_SITE into environment, to fix the compose file
2025-07-11 16:15:17 +02:00
28 changed files with 734 additions and 150 deletions

View File

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

View File

@@ -1,5 +0,0 @@

> palmr-docs@3.1-beta lint /Users/daniel/clones/Palmr/apps/docs
> eslint "src/**/*.+(ts|tsx)"

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
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
ports:
- "5487:5487" # Web interface
@@ -102,9 +103,10 @@ 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
- PALMR_UID=1000 # UID for the container processes (default is 1001)
- PALMR_GID=1000 # GID for the container processes (default is 1001)
# - DISABLE_FILESYSTEM_ENCRYPTION=false # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional)
# - SECURE_SITE=false # Set to true if you are using a reverse proxy
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
@@ -128,13 +130,16 @@ docker-compose up -d
Configure Palmr. behavior through environment variables:
| Variable | Default | Description |
| ---------------- | ------- | ------------------------------------------------------- |
| `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 |
| Variable | Default | Description |
| ------------------------------- | ------- | ------------------------------------------------------------------------------------ |
| `ENABLE_S3` | `false` | Enable S3-compatible storage |
| `ENCRYPTION_KEY` | - | **Required** (unless encryption disabled): Minimum 32 characters for file encryption |
| `DISABLE_FILESYSTEM_ENCRYPTION` | `false` | Disable file encryption for direct filesystem access |
| `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.
> **⚠️ Security Warning**: Always change the `ENCRYPTION_KEY` in production when encryption is enabled. This key encrypts your files - losing it makes files permanently inaccessible.
> **🔓 File Encryption Control**: The `DISABLE_FILESYSTEM_ENCRYPTION` variable allows you to store files without encryption for direct filesystem access. When set to `true`, the `ENCRYPTION_KEY` becomes optional. **Important**: Once set, this configuration is permanent for your deployment. Switching between encrypted and unencrypted modes will break file access for existing uploads. Choose your strategy before uploading files.
> **🔗 Reverse Proxy**: If deploying behind a reverse proxy (Traefik, Nginx, etc.), set `SECURE_SITE=true` and review our [Reverse Proxy Configuration](/docs/3.1-beta/reverse-proxy-configuration) guide for proper setup.
@@ -144,6 +149,8 @@ Need a strong key for `ENCRYPTION_KEY`? Use our built-in generator to create cry
<KeyGenerator />
> **💡 Pro Tip**: If you're using `DISABLE_FILESYSTEM_ENCRYPTION=true`, you can skip the `ENCRYPTION_KEY` entirely for a simpler setup. However, remember that files will be stored unencrypted on your filesystem.
---
## Accessing Palmr.
@@ -177,6 +184,8 @@ docker run -d \
--name palmr \
-e ENABLE_S3=false \
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
# -e DISABLE_FILESYSTEM_ENCRYPTION=true # Uncomment to disable file encryption (ENCRYPTION_KEY becomes optional)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
-p 5487:5487 \
-p 3333:3333 \
-v palmr_data:/app/server \
@@ -184,6 +193,8 @@ docker run -d \
kyantech/palmr:latest
```
> **Permission Configuration**: If you encounter permission issues with bind mounts (common on NAS systems), see our [UID/GID Configuration](/docs/3.1-beta/uid-gid-configuration) guide for automatic permission handling.
**Bind Mount:**
```bash
@@ -191,6 +202,10 @@ docker run -d \
--name palmr \
-e ENABLE_S3=false \
-e ENCRYPTION_KEY=your-secure-key-min-32-chars \
-e PALMR_UID=1000 # UID for the container processes (default is 1001)
-e PALMR_GID=1000 # GID for the container processes (default is 1001)
# -e DISABLE_FILESYSTEM_ENCRYPTION=true # Uncomment to disable file encryption (ENCRYPTION_KEY becomes optional)
# -e SECURE_SITE=false # Set to true if you are using a reverse proxy
-p 5487:5487 \
-p 3333:3333 \
-v $(pwd)/data:/app/server \

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-docs",
"version": "v3.1.1-beta",
"version": "3.1.2-beta",
"description": "Docs for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -1,3 +1,5 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import {
@@ -10,6 +12,7 @@ import {
LayoutIcon,
LockIcon,
MousePointer,
RadioIcon,
RocketIcon,
SearchIcon,
TimerIcon,
@@ -79,7 +82,23 @@ function Hero() {
<Link href={docsLink}>Documentation</Link>
</div>
</PulsatingButton>
<RippleButton
onClick={() => {
const demoId = `${Math.random().toString(36).substr(2, 9)}`;
const token = `${Math.random().toString(36).substr(2, 12)}`;
sessionStorage.setItem("demo_token", token);
sessionStorage.setItem("demo_id", demoId);
sessionStorage.setItem("demo_expires", (Date.now() + 5 * 60 * 1000).toString());
window.location.href = `/demo?id=${demoId}&token=${token}`;
}}
>
<div className="flex gap-2 items-center">
<RadioIcon size={18} />
Live Demo
</div>
</RippleButton>
<RippleButton>
<a
href="https://github.com/kyantech/Palmr"

View File

@@ -0,0 +1,225 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Palmtree } from "lucide-react";
import { motion } from "motion/react";
import { BackgroundLights } from "@/components/ui/background-lights";
import { Button } from "@/components/ui/button";
interface DemoStatus {
status: "waiting" | "ready";
url: string | null;
}
interface CreateDemoResponse {
message: string;
url: string | null;
}
function DemoClientInner() {
const searchParams = useSearchParams();
const demoId = searchParams.get("id");
const token = searchParams.get("token");
const [status, setStatus] = useState<DemoStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const validateAccess = () => {
const storedToken = sessionStorage.getItem("demo_token");
const storedId = sessionStorage.getItem("demo_id");
const expiresAt = sessionStorage.getItem("demo_expires");
if (!demoId || !token || !storedToken || !storedId || !expiresAt) {
return false;
}
if (token !== storedToken || demoId !== storedId || Date.now() > parseInt(expiresAt)) {
return false;
}
return true;
};
if (!validateAccess()) {
setError("Unauthorized access. Please use the Live Demo button to access this page.");
setIsLoading(false);
return;
}
const createDemo = async () => {
try {
const response = await fetch("https://palmr-demo-manager.kyantech.com.br/create-demo", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
palmr_demo_instance_id: demoId,
}),
});
if (!response.ok) {
throw new Error("Failed to create demo");
}
const data: CreateDemoResponse = await response.json();
console.log("Demo creation response:", data);
} catch (err) {
console.error("Error creating demo:", err);
setError("Failed to create demo. Please try again.");
setIsLoading(false);
}
};
const checkStatus = async () => {
try {
const response = await fetch(`https://palmr-demo-manager.kyantech.com.br/status/${demoId}`);
if (!response.ok) {
throw new Error("Failed to check demo status");
}
const data: DemoStatus = await response.json();
setStatus(data);
if (data.status === "ready" && data.url) {
setIsLoading(false);
}
} catch (err) {
console.error("Error checking status:", err);
setError("Failed to check demo status. Please try again.");
setIsLoading(false);
}
};
createDemo();
const interval = setInterval(checkStatus, 5000); // Check every 5 seconds
checkStatus();
return () => {
clearInterval(interval);
sessionStorage.removeItem("demo_token");
sessionStorage.removeItem("demo_id");
sessionStorage.removeItem("demo_expires");
};
}, [demoId, token]);
const handleGoToDemo = () => {
if (status?.url) {
window.open(status.url, "_blank");
}
window.location.href = "/";
};
if (error) {
return (
<div className="fixed inset-0 bg-background">
<BackgroundLights />
<div className="relative flex flex-col items-center justify-center h-full">
<div className="text-center space-y-6 max-w-md">
<h1 className="text-2xl font-bold text-destructive">Error</h1>
<p className="text-muted-foreground">{error}</p>
<Button
onClick={() => {
sessionStorage.removeItem("demo_token");
sessionStorage.removeItem("demo_id");
sessionStorage.removeItem("demo_expires");
window.location.href = "/";
}}
>
Go Back
</Button>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="fixed inset-0 bg-background">
<BackgroundLights />
<div className="flex flex-col items-center gap-6 text-center h-full justify-center">
<div className="space-y-4">
<h1 className="text-2xl font-bold">Your demo is being generated, please wait...</h1>
<p className="text-muted-foreground max-w-lg">
This demo will be available for 30 minutes for testing. After that, all data will be permanently deleted
and become inaccessible. You can test Palmr. with a 200MB storage limit.
</p>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-background">
<BackgroundLights />
<div className="relative flex flex-col items-center justify-center h-full">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="container mx-auto max-w-7xl px-6 flex-grow"
>
<section className="relative flex flex-col items-center justify-center gap-6 m-auto h-full">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="inline-block max-w-xl text-center justify-center"
>
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<motion.span
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="text-4xl lg:text-3xl font-semibold tracking-tight text-primary"
>
Your demo is ready!
</motion.span>
<motion.span
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.6, duration: 0.5 }}
className="text-3xl leading-9 font-semibold tracking-tight"
>
Click the button below to test
</motion.span>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
className="flex flex-col items-center gap-6"
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 1.2, duration: 0.5 }}
>
<Button onClick={handleGoToDemo} className="flex items-center gap-2 px-8 py-4 text-lg">
<Palmtree className="h-5 w-5" />
Go to Palmr. Demo
</Button>
</motion.div>
</motion.div>
</section>
</motion.div>
</div>
</div>
);
}
export default function DemoClient() {
return <DemoClientInner />;
}

View File

@@ -0,0 +1,13 @@
"use client";
import { Suspense } from "react";
import DemoClient from "./components/demo-client";
export default function DemoPage() {
return (
<Suspense>
<DemoClient />
</Suspense>
);
}

View File

@@ -0,0 +1,33 @@
import { motion } from "motion/react";
export function BackgroundLights() {
return (
<div className="absolute inset-0 -z-10 overflow-hidden">
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.3, 0.5, 0.3],
}}
className="absolute -top-[20%] -left-[20%] w-[140%] h-[140%] bg-[radial-gradient(circle,rgba(34,197,94,0.15)_0%,transparent_70%)] dark:opacity-100 opacity-50"
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
}}
/>
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.3, 0.5, 0.3],
}}
className="absolute -bottom-[20%] -right-[20%] w-[140%] h-[140%] bg-[radial-gradient(circle,rgba(34,197,94,0.15)_0%,transparent_70%)] dark:opacity-100 opacity-50"
transition={{
duration: 5,
repeat: Infinity,
ease: "easeInOut",
delay: 2.5,
}}
/>
</div>
);
}

View File

@@ -1,5 +0,0 @@

> palmr-api@3.1-beta lint /Users/daniel/clones/Palmr/apps/server
> eslint "src/**/*.+(ts|tsx)"

View File

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

View File

@@ -24,7 +24,7 @@ export async function buildApp() {
},
},
logger: {
level: "info",
level: "warn",
},
bodyLimit: 1024 * 1024 * 1024 * 1024 * 1024,
connectionTimeout: 0,

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
const envSchema = z.object({
ENABLE_S3: z.union([z.literal("true"), z.literal("false")]).default("false"),
ENCRYPTION_KEY: z.string().optional().default("palmr-default-encryption-key-2025"),
DISABLE_FILESYSTEM_ENCRYPTION: z.union([z.literal("true"), z.literal("false")]).default("false"),
S3_ENDPOINT: z.string().optional(),
S3_PORT: z.string().optional(),
S3_USE_SSL: z.string().optional(),
@@ -13,6 +14,7 @@ const envSchema = z.object({
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"),
DEMO_MODE: z.union([z.literal("true"), z.literal("false")]).default("false"),
});
export const env = envSchema.parse(process.env);

View File

@@ -1,5 +1,6 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { env } from "../../env";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import { CheckFileInput, CheckFileSchema, RegisterFileInput, RegisterFileSchema, UpdateFileSchema } from "./dto";
@@ -55,7 +56,17 @@ export class FileController {
});
}
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
// Check if DEMO_MODE is enabled
const isDemoMode = env.DEMO_MODE === "true";
let maxTotalStorage: bigint;
if (isDemoMode) {
// In demo mode, limit all users to 200MB
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
} else {
// Normal behavior - use maxTotalStoragePerUser configuration
maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
}
const userFiles = await prisma.file.findMany({
where: { userId },
@@ -127,7 +138,17 @@ export class FileController {
});
}
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
// Check if DEMO_MODE is enabled
const isDemoMode = env.DEMO_MODE === "true";
let maxTotalStorage: bigint;
if (isDemoMode) {
// In demo mode, limit all users to 200MB
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
} else {
// Normal behavior - use maxTotalStoragePerUser configuration
maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
}
const userFiles = await prisma.file.findMany({
where: { userId },

View File

@@ -92,7 +92,6 @@ export class ChunkManager {
console.log(`Chunk ${chunkIndex} already uploaded, treating as success`);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
// Check if already finalizing to prevent race condition
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
@@ -128,7 +127,6 @@ export class ChunkManager {
);
if (isLastChunk && chunkInfo.uploadedChunks.size === totalChunks) {
// Check if already finalizing to prevent race condition
if (this.finalizingUploads.has(fileId)) {
console.log(`Upload ${fileId} is already being finalized, waiting...`);
return { isComplete: false };
@@ -199,7 +197,7 @@ export class ChunkManager {
}
/**
* Finalize upload by moving temp file to final location and encrypting
* Finalize upload by moving temp file to final location and encrypting (if enabled)
*/
private async finalizeUpload(
chunkInfo: ChunkInfo,
@@ -224,7 +222,7 @@ export class ChunkManager {
const filePath = provider.getFilePath(finalObjectName);
const dir = path.dirname(filePath);
console.log(`Starting encryption and finalization: ${finalObjectName}`);
console.log(`Starting finalization: ${finalObjectName}`);
await fs.promises.mkdir(dir, { recursive: true });
@@ -236,7 +234,6 @@ export class ChunkManager {
});
const encryptStream = provider.createEncryptStream();
// Wait for encryption to complete BEFORE cleaning up temp file
await new Promise<void>((resolve, reject) => {
const startTime = Date.now();
@@ -245,18 +242,17 @@ export class ChunkManager {
.pipe(writeStream)
.on("finish", () => {
const duration = Date.now() - startTime;
console.log(`File encrypted and saved to: ${filePath} in ${duration}ms`);
console.log(`File processed and saved to: ${filePath} in ${duration}ms`);
resolve();
})
.on("error", (error) => {
console.error("Error during encryption:", error);
console.error("Error during processing:", error);
reject(error);
});
});
console.log(`File successfully uploaded and encrypted: ${finalObjectName}`);
console.log(`File successfully uploaded and processed: ${finalObjectName}`);
// Clean up temp file AFTER encryption is complete
await this.cleanupTempFile(chunkInfo.tempPath);
this.activeUploads.delete(chunkInfo.fileId);

View File

@@ -1,5 +1,6 @@
import { PrismaClient } from "@prisma/client";
import { env } from "../../env";
import { FileService } from "../file/service";
import {
CreateReverseShareInput,
@@ -513,7 +514,18 @@ export class ReverseShareService {
throw new Error(`File size exceeds the maximum allowed size of ${maxSizeMB}MB`);
}
const maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
// Check if DEMO_MODE is enabled
const isDemoMode = env.DEMO_MODE === "true";
let maxTotalStorage: bigint;
if (isDemoMode) {
// In demo mode, limit all users to 200MB
maxTotalStorage = BigInt(200 * 1024 * 1024); // 200MB in bytes
} else {
// Normal behavior - use maxTotalStoragePerUser configuration
maxTotalStorage = BigInt(await configService.getValue("maxTotalStoragePerUser"));
}
const userFiles = await prisma.file.findMany({
where: { userId: creatorId },
select: { size: true },

View File

@@ -3,6 +3,7 @@ import fs from "node:fs";
import { promisify } from "util";
import { PrismaClient } from "@prisma/client";
import { env } from "../../env";
import { IS_RUNNING_IN_CONTAINER } from "../../utils/container-detection";
import { ConfigService } from "../config/service";
@@ -21,6 +22,30 @@ export class StorageService {
return Number.isNaN(parsed) ? 0 : parsed;
}
private _parseSize(value: string): number {
if (!value) return 0;
const cleanValue = value.trim().toLowerCase();
const numericMatch = cleanValue.match(/^(\d+(?:\.\d+)?)/);
if (!numericMatch) return 0;
const numericValue = parseFloat(numericMatch[1]);
if (Number.isNaN(numericValue)) return 0;
if (cleanValue.includes("t")) {
return Math.round(numericValue * 1024 * 1024 * 1024 * 1024);
} else if (cleanValue.includes("g")) {
return Math.round(numericValue * 1024 * 1024 * 1024);
} else if (cleanValue.includes("m")) {
return Math.round(numericValue * 1024 * 1024);
} else if (cleanValue.includes("k")) {
return Math.round(numericValue * 1024);
} else {
return Math.round(numericValue);
}
}
private async _tryDiskSpaceCommand(command: string): Promise<{ total: number; available: number } | null> {
try {
const { stdout, stderr } = await execAsync(command);
@@ -54,16 +79,61 @@ export class StorageService {
}
} 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 (command.includes("findmnt")) {
if (lines.length >= 1) {
const parts = lines[0].trim().split(/\s+/);
if (parts.length >= 2) {
const [availStr, sizeStr] = parts;
available = this._parseSize(availStr);
total = this._parseSize(sizeStr);
}
}
} else if (command.includes("stat -f")) {
let blockSize = 0;
let totalBlocks = 0;
let freeBlocks = 0;
for (const line of lines) {
if (line.includes("Block size:")) {
blockSize = this._safeParseInt(line.split(":")[1].trim());
} else if (line.includes("Total blocks:")) {
totalBlocks = this._safeParseInt(line.split(":")[1].trim());
} else if (line.includes("Free blocks:")) {
freeBlocks = this._safeParseInt(line.split(":")[1].trim());
}
}
if (blockSize > 0 && totalBlocks > 0) {
total = totalBlocks * blockSize;
available = freeBlocks * blockSize;
} else {
return null;
}
} else if (command.includes("--output=")) {
if (lines.length >= 2) {
const parts = lines[1].trim().split(/\s+/);
if (parts.length >= 2) {
const [availStr, sizeStr] = parts;
available = this._safeParseInt(availStr) * 1024;
total = this._safeParseInt(sizeStr) * 1024;
}
}
} else {
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 if (command.includes("-h")) {
total = this._parseSize(size);
available = this._parseSize(avail);
} else {
total = this._safeParseInt(size) * 1024;
available = this._safeParseInt(avail) * 1024;
}
}
}
}
@@ -72,18 +142,13 @@ export class StorageService {
if (total > 0 && available >= 0) {
return { total, available };
} else {
console.warn(`Invalid values parsed: total=${total}, available=${available}`);
return null;
}
} catch (error) {
console.warn(`Command failed: ${command}`, error);
} catch {
return null;
}
}
/**
* Gets detailed mount information for debugging
*/
private async _getMountInfo(path: string): Promise<{ filesystem: string; mountPoint: string; type: string } | null> {
try {
if (!fs.existsSync("/proc/mounts")) {
@@ -109,16 +174,11 @@ export class StorageService {
}
return bestMatch;
} catch (error) {
console.warn(`Could not get mount info for ${path}:`, error);
} catch {
return null;
}
}
/**
* Detects if a path is a bind mount or mount point by checking /proc/mounts
* Returns the actual filesystem path for bind mounts
*/
private async _detectMountPoint(path: string): Promise<string | null> {
try {
if (!fs.existsSync("/proc/mounts")) {
@@ -133,9 +193,8 @@ export class StorageService {
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const [, mountPoint] = parts;
if (parts.length >= 3) {
const [device, mountPoint, filesystem] = parts;
if (path.startsWith(mountPoint) && mountPoint.length > bestMatchLength) {
bestMatch = mountPoint;
bestMatchLength = mountPoint.length;
@@ -148,24 +207,16 @@ export class StorageService {
}
return null;
} catch (error) {
console.warn(`Could not detect mount point for ${path}:`, error);
} catch {
return null;
}
}
/**
* Gets filesystem information for a specific path, with bind mount detection
*/
private async _getFileSystemInfo(
path: string
): Promise<{ total: number; available: number; mountPoint?: string } | null> {
try {
const mountInfo = await this._getMountInfo(path);
if (mountInfo && mountInfo.mountPoint !== "/") {
console.log(`📁 Bind mount detected: ${path}${mountInfo.filesystem} (${mountInfo.type})`);
}
const mountPoint = await this._detectMountPoint(path);
const targetPath = mountPoint || path;
@@ -174,7 +225,18 @@ export class StorageService {
? ["wmic logicaldisk get size,freespace,caption"]
: process.platform === "darwin"
? [`df -k "${targetPath}"`, `df "${targetPath}"`]
: [`df -B1 "${targetPath}"`, `df -k "${targetPath}"`, `df "${targetPath}"`];
: [
`df -B1 "${targetPath}"`,
`df -k "${targetPath}"`,
`df "${targetPath}"`,
`df -h "${targetPath}"`,
`df -T "${targetPath}"`,
`stat -f "${targetPath}"`,
`findmnt -n -o AVAIL,SIZE "${targetPath}"`,
`findmnt -n -o AVAIL,SIZE,TARGET "${targetPath}"`,
`df -P "${targetPath}"`,
`df --output=avail,size "${targetPath}"`,
];
for (const command of commandsToTry) {
const result = await this._tryDiskSpaceCommand(command);
@@ -187,25 +249,54 @@ export class StorageService {
}
return null;
} catch (error) {
console.warn(`Error getting filesystem info for ${path}:`, error);
} catch {
return null;
}
}
private async _detectSynologyVolumes(): Promise<string[]> {
try {
if (!fs.existsSync("/proc/mounts")) {
return [];
}
const mountsContent = await fs.promises.readFile("/proc/mounts", "utf8");
const lines = mountsContent.split("\n").filter((line) => line.trim());
const synologyPaths: string[] = [];
for (const line of lines) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const [, mountPoint] = parts;
if (mountPoint.match(/^\/volume\d+$/)) {
synologyPaths.push(mountPoint);
}
}
}
return synologyPaths;
} catch {
return [];
}
}
private async _getDiskSpaceMultiplePaths(): Promise<{ total: number; available: number } | null> {
const pathsToTry = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server", "/app", "/"]
const basePaths = IS_RUNNING_IN_CONTAINER
? ["/app/server/uploads", "/app/server/temp-uploads", "/app/server/temp-chunks", "/app/server", "/app", "/"]
: [".", "./uploads", process.cwd()];
const synologyPaths = await this._detectSynologyVolumes();
const pathsToTry = [...basePaths, ...synologyPaths];
for (const pathToCheck of pathsToTry) {
if (pathToCheck.includes("uploads")) {
if (pathToCheck.includes("uploads") || pathToCheck.includes("temp-")) {
try {
if (!fs.existsSync(pathToCheck)) {
fs.mkdirSync(pathToCheck, { recursive: true });
}
} catch (err) {
console.warn(`Could not create path ${pathToCheck}:`, err);
} catch {
continue;
}
}
@@ -214,12 +305,8 @@ export class StorageService {
continue;
}
// Use the new filesystem detection method
const result = await this._getFileSystemInfo(pathToCheck);
if (result) {
if (result.mountPoint) {
console.log(`✅ Storage resolved via bind mount: ${result.mountPoint}`);
}
return { total: result.total, available: result.available };
}
}
@@ -237,52 +324,94 @@ export class StorageService {
uploadAllowed: boolean;
}> {
try {
const isDemoMode = env.DEMO_MODE === "true";
if (isAdmin) {
const diskInfo = await this._getDiskSpaceMultiplePaths();
if (isDemoMode) {
const demoMaxStorage = 200 * 1024 * 1024;
const demoMaxStorageGB = this._ensureNumber(demoMaxStorage / (1024 * 1024 * 1024), 0);
if (!diskInfo) {
console.error("❌ Could not determine disk space - system configuration issue");
throw new Error("Unable to determine actual disk space - system configuration issue");
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(demoMaxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: Number(demoMaxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
} else {
const diskInfo = await this._getDiskSpaceMultiplePaths();
if (!diskInfo) {
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);
return {
diskSizeGB: Number(diskSizeGB.toFixed(2)),
diskUsedGB: Number(diskUsedGB.toFixed(2)),
diskAvailableGB: Number(diskAvailableGB.toFixed(2)),
uploadAllowed: diskAvailableGB > 0.1,
};
}
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);
return {
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 = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
if (isDemoMode) {
const demoMaxStorage = 200 * 1024 * 1024;
const demoMaxStorageGB = this._ensureNumber(demoMaxStorage / (1024 * 1024 * 1024), 0);
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(demoMaxStorageGB - usedStorageGB, 0);
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return {
diskSizeGB: Number(demoMaxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
} else {
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = this._ensureNumber(Number(maxTotalStorage) / (1024 * 1024 * 1024), 10);
return {
diskSizeGB: Number(maxStorageGB.toFixed(2)),
diskUsedGB: Number(usedStorageGB.toFixed(2)),
diskAvailableGB: Number(availableStorageGB.toFixed(2)),
uploadAllowed: availableStorageGB > 0,
};
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = this._ensureNumber(Number(totalUsedStorage) / (1024 * 1024 * 1024), 0);
const availableStorageGB = this._ensureNumber(maxStorageGB - usedStorageGB, 0);
return {
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);
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

@@ -8,12 +8,12 @@ import { pipeline } from "stream/promises";
import { directoriesConfig, getTempFilePath } from "../config/directories.config";
import { env } from "../env";
import { StorageProvider } from "../types/storage";
import { IS_RUNNING_IN_CONTAINER } from "../utils/container-detection";
export class FilesystemStorageProvider implements StorageProvider {
private static instance: FilesystemStorageProvider;
private uploadsDir: string;
private encryptionKey = env.ENCRYPTION_KEY;
private isEncryptionDisabled = env.DISABLE_FILESYSTEM_ENCRYPTION === "true";
private uploadTokens = new Map<string, { objectName: string; expiresAt: number }>();
private downloadTokens = new Map<string, { objectName: string; expiresAt: number; fileName?: string }>();
@@ -66,6 +66,15 @@ export class FilesystemStorageProvider implements StorageProvider {
}
public createEncryptStream(): Transform {
if (this.isEncryptionDisabled) {
return new Transform({
transform(chunk, encoding, callback) {
this.push(chunk);
callback();
},
});
}
const key = this.createEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
@@ -101,6 +110,15 @@ export class FilesystemStorageProvider implements StorageProvider {
}
public createDecryptStream(): Transform {
if (this.isEncryptionDisabled) {
return new Transform({
transform(chunk, encoding, callback) {
this.push(chunk);
callback();
},
});
}
const key = this.createEncryptionKey();
let iv: Buffer | null = null;
let decipher: crypto.Decipher | null = null;
@@ -213,32 +231,26 @@ export class FilesystemStorageProvider implements StorageProvider {
}
}
private encryptFileBuffer(buffer: Buffer): Buffer {
const key = this.createEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
const encrypted = Buffer.concat([iv, cipher.update(buffer), cipher.final()]);
return encrypted;
}
async downloadFile(objectName: string): Promise<Buffer> {
const filePath = this.getFilePath(objectName);
const encryptedBuffer = await fs.readFile(filePath);
const fileBuffer = await fs.readFile(filePath);
if (encryptedBuffer.length > 16) {
if (this.isEncryptionDisabled) {
return fileBuffer;
}
if (fileBuffer.length > 16) {
try {
return this.decryptFileBuffer(encryptedBuffer);
return this.decryptFileBuffer(fileBuffer);
} catch (error: unknown) {
if (error instanceof Error) {
console.warn("Failed to decrypt with new method, trying legacy format", error.message);
}
return this.decryptFileLegacy(encryptedBuffer);
return this.decryptFileLegacy(fileBuffer);
}
}
return this.decryptFileLegacy(encryptedBuffer);
return this.decryptFileLegacy(fileBuffer);
}
private decryptFileBuffer(encryptedBuffer: Buffer): Buffer {

View File

@@ -101,7 +101,9 @@ async function startServer() {
}
console.log(`🌴 Palmr server running on port 3333 🌴`);
console.log(`📦 Storage mode: ${env.ENABLE_S3 === "true" ? "S3" : "Local Filesystem (Encrypted)"}`);
console.log(
`📦 Storage mode: ${env.ENABLE_S3 === "true" ? "S3" : `Local Filesystem ${env.DISABLE_FILESYSTEM_ENCRYPTION === "true" ? "(Unencrypted)" : "(Encrypted)"}`}`
);
console.log(`🔐 Auth Providers: ${authProviders}`);
console.log("\n📚 API Documentation:");

View File

@@ -1,5 +0,0 @@

> palmr-web@3.1-beta lint /Users/daniel/clones/Palmr/apps/web
> eslint "src/**/*.+(ts|tsx)"

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "v3.1.1-beta",
"version": "3.1.2-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -104,8 +104,10 @@ export function EditProviderForm({
{ pattern: "linkedin.com", scopes: ["r_liteprofile", "r_emailaddress"] },
{ pattern: "auth0.com", scopes: ["openid", "profile", "email"] },
{ pattern: "okta.com", scopes: ["openid", "profile", "email"] },
{ pattern: "authentik", scopes: ["openid", "profile", "email"] },
{ pattern: "kinde.com", scopes: ["openid", "profile", "email"] },
{ pattern: "zitadel.com", scopes: ["openid", "profile", "email"] },
{ pattern: "pocketid", scopes: ["openid", "profile", "email"] },
];
for (const { pattern, scopes } of providerPatterns) {

View File

@@ -4,10 +4,11 @@ services:
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
- PALMR_UID=1000 # UID for the container processes (default is 1001) you can change it to the UID of the user running the container
- PALMR_GID=1000 # GID for the container processes (default is 1001) you can change it to the GID of the user running the container
- SECURE_SITE=false # Set to true if you are using a reverse proxy
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY (REQUIRED if DISABLE_FILESYSTEM_ENCRYPTION is false)
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional) | (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)

View File

@@ -12,9 +12,9 @@ services:
- S3_REGION=${S3_REGION:-us-east-1} # S3 region (us-east-1 is the default region) but it depends on your s3 server region
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-palmr-files} # Bucket name for the S3 storage (here we are using palmr-files as the bucket name to understand that this is the bucket for palmr)
- S3_FORCE_PATH_STYLE=true # For MinIO compatibility we have to set this to true
- PALMR_UID=1000 # UID for the container processes (default is 1001) you can change it to the UID of the user running the container
- PALMR_GID=1000 # GID for the container processes (default is 1001) you can change it to the GID of the user running the container
- SECURE_SITE=false # Set to true if you are using a reverse proxy
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)

View File

@@ -12,9 +12,9 @@ services:
- S3_REGION=${S3_REGION:-us-east-1} # S3 region (us-east-1 is the default region) but it depends on your s3 server region
- S3_BUCKET_NAME=${S3_BUCKET_NAME:-palmr-files} # Bucket name for the S3 storage (here we are using palmr-files as the bucket name to understand that this is the bucket for palmr)
- S3_FORCE_PATH_STYLE=false # For S3 compatibility we have to set this to false
- PALMR_UID=1000 # UID for the container processes (default is 1001) you can change it to the UID of the user running the container
- PALMR_GID=1000 # GID for the container processes (default is 1001) you can change it to the GID of the user running the container
- SECURE_SITE=false # Set to true if you are using a reverse proxy
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)

View File

@@ -0,0 +1,23 @@
version: "3.8"
services:
palmr:
image: kyantech/palmr:v3.1.1-rc.1
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=palmr-test-encryption-key-2025
- PALMR_UID=1000 # UID for Synology NAS compatibility (default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for Synology NAS compatibility (default is 1001) | See our UID/GID Documentation for more information
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional) | (OPTIONAL - default is false)
# Add any other environment variables as needed
ports:
- "5487:5487"
- "3333:3333"
volumes:
- /volume1/docker/palmr/uploads:/app/server/uploads
- /volume1/docker/palmr/temp-uploads:/app/server/temp-uploads
- /volume1/docker/palmr/temp-chunks:/app/server/temp-chunks
- /volume1/docker/palmr/prisma:/app/server/prisma
restart: unless-stopped

View File

@@ -4,13 +4,14 @@ services:
container_name: palmr
environment:
- ENABLE_S3=false
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY
- ENCRYPTION_KEY=change-this-key-in-production-min-32-chars # CHANGE THIS KEY FOR SECURITY (REQUIRED if DISABLE_FILESYSTEM_ENCRYPTION is false)
- PALMR_UID=1000 # UID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
- PALMR_GID=1000 # GID for the container processes (OPTIONAL - default is 1001) | See our UID/GID Documentation for more information
# - SECURE_SITE=true # Set to true if you are using a reverse proxy (OPTIONAL - default is false)
# - DISABLE_FILESYSTEM_ENCRYPTION=true # Set to true to disable file encryption (ENCRYPTION_KEY becomes optional) | (OPTIONAL - default is false)
ports:
- "5487:5487" # Web port
- "3333:3333" # API port (OPTIONAL EXPOSED - ONLY IF YOU WANT TO ACCESS THE API DIRECTLY)
- PALMR_UID=1000 # UID for the container processes (default is 1001) you can change it to the UID of the user running the container
- PALMR_GID=1000 # GID for the container processes (default is 1001) you can change it to the GID of the user running the container
- SECURE_SITE=false # Set to true if you are using a reverse proxy
volumes:
- palmr_data:/app/server # Volume for the application data (changed from /data to /app/server)
restart: unless-stopped # Restart the container unless it is stopped

View File

@@ -86,6 +86,19 @@ else
return;
}
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('true');
return;
}
console.log('false');
} catch (error) {
console.log('true');
@@ -117,6 +130,19 @@ else
return;
}
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('true');
return;
}
console.log('false');
} catch (error) {
console.log('true');
@@ -132,6 +158,72 @@ else
if [ "$NEEDS_SEEDING" = "true" ]; then
echo "🌱 New tables detected or missing data, running seed..."
# Check which providers are missing for better logging
MISSING_PROVIDERS=$(
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkMissingProviders() {
try {
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('Missing providers: ' + missingProviders.join(', '));
} else {
console.log('No missing providers');
}
} catch (error) {
console.log('Error checking providers');
} finally {
await prisma.\$disconnect();
}
}
checkMissingProviders();
" 2>/dev/null || echo "Error checking providers"
else
node -e "
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkMissingProviders() {
try {
const expectedProviders = ['google', 'discord', 'github', 'auth0', 'kinde', 'zitadel', 'authentik', 'frontegg', 'pocketid'];
const existingProviders = await prisma.authProvider.findMany({
select: { name: true }
});
const existingProviderNames = existingProviders.map(p => p.name);
const missingProviders = expectedProviders.filter(name => !existingProviderNames.includes(name));
if (missingProviders.length > 0) {
console.log('Missing providers: ' + missingProviders.join(', '));
} else {
console.log('No missing providers');
}
} catch (error) {
console.log('Error checking providers');
} finally {
await prisma.\$disconnect();
}
}
checkMissingProviders();
" 2>/dev/null || echo "Error checking providers"
fi
)
if [ "$MISSING_PROVIDERS" != "No missing providers" ] && [ "$MISSING_PROVIDERS" != "Error checking providers" ]; then
echo "🔍 $MISSING_PROVIDERS"
fi
if [ "$(id -u)" = "0" ]; then
su-exec $TARGET_UID:$TARGET_GID node ./prisma/seed.js
else

View File

@@ -20,7 +20,7 @@ environment=PORT=3333,HOME="/home/palmr"
priority=100
[program:web]
command=/bin/sh -c 'echo "Waiting for API to be ready..."; while ! curl -f http://127.0.0.1:3333/health >/dev/null 2>&1; do echo "API not ready, waiting..."; sleep 2; done; echo "API is ready! Starting frontend..."; exec node server.js'
command=/bin/sh -c 'echo "Please wait while Palmr. is starting..."; while ! curl -f http://127.0.0.1:3333/health >/dev/null 2>&1; do echo "1/2 - Palmr. starting, be patient..."; sleep 2; done; echo "2/2 - Palmr. starting, be patient..."; exec node server.js'
directory=/app/web
user=palmr
autostart=true