feat: input developed code

This commit is contained in:
danielalves96
2025-02-24 23:58:40 -03:00
parent 33ab802b60
commit f583e4d6f6
464 changed files with 29448 additions and 7 deletions

View File

@@ -1,7 +0,0 @@
{
"eslint.workingDirectories": [
{
"mode": "auto"
}
]
}

25
LICENSE Normal file
View File

@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2022, Daniel Luiz Alves (danielalves96)
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

1
README.md Normal file
View File

@@ -0,0 +1 @@
# Palmr.

View File

@@ -0,0 +1,9 @@
node_modules
dist
.env
.env.*
*.log
.git
.gitignore
.next
.cache

12
apps/server/.env.example Normal file
View File

@@ -0,0 +1,12 @@
DATABASE_URL="postgresql://palmr:palmr123@localhost:5432/palmr?schema=public"
FRONTEND_URL="http://localhost:3000"
MINIO_ENDPOINT="localhost"
MINIO_PORT="9000"
MINIO_USE_SSL="false"
MINIO_ROOT_USER="palmr"
MINIO_ROOT_PASSWORD="palmr123"
MINIO_REGION="sa-east-1"
MINIO_BUCKET_NAME="files"
PORT=3333

3
apps/server/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.env
dist/*

View File

@@ -0,0 +1,7 @@
{
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"printWidth": 120,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}

26
apps/server/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:18
WORKDIR /app/server
RUN apt-get update && apt-get install -y netcat-traditional
RUN npm install -g pnpm
COPY package*.json ./
COPY pnpm-lock.yaml ./
COPY prisma ./prisma/
COPY scripts ./scripts/
RUN rm -rf node_modules/.prisma
RUN pnpm install --frozen-lockfile
RUN npx prisma generate
COPY . .
RUN pnpm build
RUN chmod +x ./scripts/start.sh
EXPOSE 3333
CMD ["./scripts/start.sh"]

View File

@@ -0,0 +1,62 @@
services:
minio:
image: minio/minio:latest
container_name: minio
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=palmr
- MINIO_ROOT_PASSWORD=palmr123
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
restart: "unless-stopped"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/ready"]
interval: 10s
timeout: 5s
retries: 5
minio-init:
image: minio/mc:latest
container_name: minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
sh -c "
mc alias set myminio http://minio:9000 palmr palmr123 &&
mc mb myminio/files --ignore-existing
"
restart: "no"
environment:
MINIO_ROOT_USER: palmr
MINIO_ROOT_PASSWORD: palmr123
postgres:
image: bitnami/postgresql:17.2.0
container_name: palmr-postgres
ports:
- "5432:5432"
environment:
- POSTGRESQL_USERNAME=palmr
- POSTGRESQL_PASSWORD=palmr123
- POSTGRESQL_DATABASE=palmr
volumes:
- postgres_data:/bitnami/postgresql
restart: "unless-stopped"
healthcheck:
test: ["CMD", "pg_isready", "-U", "palmr"]
interval: 10s
timeout: 5s
retries: 5
volumes:
minio_data:
postgres_data:
networks:
default:
name: palmr-network
driver: bridge

View File

@@ -0,0 +1,24 @@
import pluginJs from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier";
import importPlugin from "eslint-plugin-import";
import globals from "globals";
import tseslint from "typescript-eslint";
/** @type {import('eslint').Linter.Config[]} */
export default [
{ files: ["**/*.{js,mjs,cjs,ts}"] },
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
importPlugin.flatConfigs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
{
rules: {
"import/no-unresolved": "off",
"import/no-named-as-default": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-require-imports": "off",
},
},
];

53
apps/server/package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "palmr-api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
"format": "prettier . --write",
"format:check": "prettier . --check",
"db:seed": "ts-node prisma/seed.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.2",
"@fastify/jwt": "^9.0.3",
"@fastify/multipart": "^9.0.3",
"@fastify/swagger": "^9.4.2",
"@fastify/swagger-ui": "^5.2.1",
"@prisma/client": "^6.3.1",
"@scalar/fastify-api-reference": "^1.25.116",
"bcryptjs": "^2.4.3",
"fastify": "^5.2.1",
"fastify-type-provider-zod": "^4.0.2",
"minio": "^8.0.4",
"nodemailer": "^6.10.0",
"sharp": "^0.33.5",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"globals": "^15.14.0",
"prettier": "3.4.2",
"prisma": "^6.3.1",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.23.0"
}
}

4243
apps/server/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,178 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"image" TEXT,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "files" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"extension" TEXT NOT NULL,
"size" BIGINT NOT NULL,
"objectName" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "files_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "shares" (
"id" TEXT NOT NULL,
"name" TEXT,
"views" INTEGER NOT NULL DEFAULT 0,
"expiration" TIMESTAMP(3),
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"creatorId" TEXT,
"securityId" TEXT NOT NULL,
CONSTRAINT "shares_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "share_security" (
"id" TEXT NOT NULL,
"password" TEXT,
"maxViews" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "share_security_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "share_recipients" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"shareId" TEXT NOT NULL,
CONSTRAINT "share_recipients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "app_configs" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"type" TEXT NOT NULL,
"group" TEXT NOT NULL,
"isSystem" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "app_configs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "login_attempts" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 1,
"lastAttempt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "login_attempts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "password_resets" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"used" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "password_resets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "share_aliases" (
"id" TEXT NOT NULL,
"alias" TEXT NOT NULL,
"shareId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "share_aliases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_ShareFiles" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_ShareFiles_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "shares_securityId_key" ON "shares"("securityId");
-- CreateIndex
CREATE UNIQUE INDEX "app_configs_key_key" ON "app_configs"("key");
-- CreateIndex
CREATE UNIQUE INDEX "login_attempts_userId_key" ON "login_attempts"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "password_resets_token_key" ON "password_resets"("token");
-- CreateIndex
CREATE UNIQUE INDEX "share_aliases_alias_key" ON "share_aliases"("alias");
-- CreateIndex
CREATE UNIQUE INDEX "share_aliases_shareId_key" ON "share_aliases"("shareId");
-- CreateIndex
CREATE INDEX "_ShareFiles_B_index" ON "_ShareFiles"("B");
-- AddForeignKey
ALTER TABLE "files" ADD CONSTRAINT "files_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shares" ADD CONSTRAINT "shares_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shares" ADD CONSTRAINT "shares_securityId_fkey" FOREIGN KEY ("securityId") REFERENCES "share_security"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "share_recipients" ADD CONSTRAINT "share_recipients_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "shares"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "login_attempts" ADD CONSTRAINT "login_attempts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "password_resets" ADD CONSTRAINT "password_resets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "share_aliases" ADD CONSTRAINT "share_aliases_shareId_fkey" FOREIGN KEY ("shareId") REFERENCES "shares"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ShareFiles" ADD CONSTRAINT "_ShareFiles_A_fkey" FOREIGN KEY ("A") REFERENCES "files"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ShareFiles" ADD CONSTRAINT "_ShareFiles_B_fkey" FOREIGN KEY ("B") REFERENCES "shares"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,145 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
firstName String
lastName String
username String @unique
email String @unique
password String
image String?
isAdmin Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
files File[]
shares Share[]
loginAttempts LoginAttempt?
passwordResets PasswordReset[]
@@map("users")
}
model File {
id String @id @default(cuid())
name String
description String?
extension String
size BigInt
objectName String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shares Share[] @relation("ShareFiles")
@@map("files")
}
model Share {
id String @id @default(cuid())
name String?
views Int @default(0)
expiration DateTime?
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creatorId String?
creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull)
securityId String @unique
security ShareSecurity @relation(fields: [securityId], references: [id])
files File[] @relation("ShareFiles")
recipients ShareRecipient[]
alias ShareAlias?
@@map("shares")
}
model ShareSecurity {
id String @id @default(cuid())
password String?
maxViews Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
share Share?
@@map("share_security")
}
model ShareRecipient {
id String @id @default(cuid())
email String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
shareId String
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
@@map("share_recipients")
}
model AppConfig {
id String @id @default(cuid())
key String @unique
value String
type String
group String
isSystem Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("app_configs")
}
model LoginAttempt {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
attempts Int @default(1)
lastAttempt DateTime @default(now())
@@map("login_attempts")
}
model PasswordReset {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
expiresAt DateTime
used Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("password_resets")
}
model ShareAlias {
id String @id @default(cuid())
alias String @unique
shareId String @unique
share Share @relation(fields: [shareId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("share_aliases")
}

178
apps/server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,178 @@
import { prisma } from "../src/shared/prisma";
import bcrypt from "bcryptjs";
import crypto from "node:crypto";
const defaultConfigs = [
// General Configurations
{
key: "appName",
value: "Palmr. ",
type: "string",
group: "general",
},
{
key: "showHomePage",
value: "true",
type: "boolean",
group: "general",
},
{
key: "appDescription",
value: "Secure and simple file sharing - Your personal cloud",
type: "string",
group: "general",
},
{
key: "appLogo",
value: "https://i.ibb.co/xSjDkgVT/image-1.png",
type: "string",
group: "general",
},
// Storage Configurations
{
key: "maxFileSize",
value: "1073741824", // 1GB in bytes
type: "bigint",
group: "storage",
},
{
key: "maxTotalStoragePerUser",
value: "10737418240", // 10GB in bytes
type: "bigint",
group: "storage",
},
// Security Configurations
{
key: "jwtSecret",
value: crypto.randomBytes(64).toString("hex"),
type: "string",
group: "security",
},
{
key: "maxLoginAttempts",
value: "5",
type: "number",
group: "security",
},
{
key: "loginBlockDuration",
value: "600", // 10 minutes in seconds
type: "number",
group: "security",
},
{
key: "passwordMinLength",
value: "8",
type: "number",
group: "security",
},
// Email Configurations
{
key: "smtpEnabled",
value: "false",
type: "boolean",
group: "email",
},
{
key: "smtpHost",
value: "smtp.gmail.com",
type: "string",
group: "email",
},
{
key: "smtpPort",
value: "587",
type: "number",
group: "email",
},
{
key: "smtpUser",
value: "your-email@gmail.com",
type: "string",
group: "email",
},
{
key: "smtpPass",
value: "your-app-specific-password",
type: "string",
group: "email",
},
{
key: "smtpFromName",
value: "Palmr",
type: "string",
group: "email",
},
{
key: "smtpFromEmail",
value: "noreply@palmr.app",
type: "string",
group: "email",
},
{
key: "passwordResetTokenExpiration",
value: "3600",
type: "number",
group: "security",
},
];
async function main() {
const existingUsers = await prisma.user.count();
if (existingUsers === 0) {
const adminEmail = "admin@example.com";
const adminPassword = "admin123";
const hashedPassword = await bcrypt.hash(adminPassword, 10);
const adminUser = await prisma.user.upsert({
where: { email: adminEmail },
update: {},
create: {
firstName: "Admin",
lastName: "User",
username: "admin",
email: adminEmail,
password: hashedPassword,
isAdmin: true,
isActive: true,
},
});
console.log("Admin user seeded:", adminUser);
} else {
console.log("Users already exist, skipping admin user creation...");
}
console.log("Seeding app configurations...");
for (const config of defaultConfigs) {
if (config.key === "jwtSecret") {
const existingSecret = await prisma.appConfig.findUnique({
where: { key: "jwtSecret" },
});
if (existingSecret) {
console.log("JWT secret already exists, skipping...");
continue;
}
}
await prisma.appConfig.upsert({
where: { key: config.key },
update: config,
create: config,
});
}
console.log("App configurations seeded successfully!");
}
main()
.catch((error) => {
console.error("Error during seed:", error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,15 @@
#!/bin/sh
echo "Waiting for PostgreSQL..."
while ! nc -z postgres 5432; do
sleep 1
done
echo "PostgreSQL is up!"
echo "Running migrations and seed..."
npx prisma generate --schema=./prisma/schema.prisma
npx prisma migrate deploy
pnpm db:seed
echo "Starting application..."
pnpm start

69
apps/server/src/app.ts Normal file
View File

@@ -0,0 +1,69 @@
import { registerSwagger } from "./config/swagger.config";
import { prisma } from "./shared/prisma";
import fastifyCookie from "@fastify/cookie";
import { fastifyCors } from "@fastify/cors";
import fastifyJwt from "@fastify/jwt";
import { fastifySwaggerUi } from "@fastify/swagger-ui";
import { fastify } from "fastify";
import { validatorCompiler, serializerCompiler, ZodTypeProvider } from "fastify-type-provider-zod";
import crypto from "node:crypto";
export async function buildApp() {
const jwtConfig = await prisma.appConfig.findUnique({
where: { key: "jwtSecret" },
});
const JWT_SECRET = jwtConfig?.value || crypto.randomBytes(64).toString("hex");
const app = fastify({
ajv: {
customOptions: {
removeAdditional: false,
},
},
}).withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);
app.addSchema({
$id: "dateFormat",
type: "string",
format: "date-time",
});
app.register(fastifyCors, {
origin: true,
credentials: true,
});
app.register(fastifyCookie);
app.register(fastifyJwt, {
secret: JWT_SECRET,
cookie: {
cookieName: "token",
signed: false,
},
sign: {
expiresIn: "1d",
},
});
app.decorateRequest("jwtSign", function (this: any, payload: object, options?: object) {
return this.server.jwt.sign(payload, options);
});
registerSwagger(app);
app.register(fastifySwaggerUi, {
routePrefix: "/swagger",
});
app.register(require("@scalar/fastify-api-reference"), {
routePrefix: "/docs",
configuration: {
theme: "deepSpace",
},
});
return app;
}

View File

@@ -0,0 +1,13 @@
import { env } from "../env";
import { Client } from "minio";
export const minioClient = new Client({
endPoint: env.MINIO_ENDPOINT,
port: Number(env.MINIO_PORT),
useSSL: env.MINIO_USE_SSL === "true",
accessKey: env.MINIO_ROOT_USER,
secretKey: env.MINIO_ROOT_PASSWORD,
region: env.MINIO_REGION,
});
export const bucketName = env.MINIO_BUCKET_NAME;

View File

@@ -0,0 +1,27 @@
import { fastifySwagger } from "@fastify/swagger";
import { jsonSchemaTransform } from "fastify-type-provider-zod";
export function registerSwagger(app: any) {
app.register(fastifySwagger, {
openapi: {
info: {
title: "🌴 Palmr. API",
description: "API documentation for Palmr file sharing system",
version: "1.0.0",
},
tags: [
{ name: "Health", description: "Health check endpoints" },
{
name: "Authentication",
description: "Authentication related endpoints",
},
{ name: "User", description: "User management endpoints" },
{ name: "File", description: "File management endpoints" },
{ name: "Share", description: "File sharing endpoints" },
{ name: "Storage", description: "Storage management endpoints" },
{ name: "App", description: "Application configuration endpoints" },
],
},
transform: jsonSchemaTransform,
});
}

17
apps/server/src/env.ts Normal file
View File

@@ -0,0 +1,17 @@
import { z } from "zod";
const envSchema = z.object({
FRONTEND_URL: z.string().url().min(1),
MINIO_ENDPOINT: z.string().min(1),
MINIO_PORT: z.string().min(1),
MINIO_USE_SSL: z.string().min(1),
MINIO_ROOT_PASSWORD: z.string().min(1),
MINIO_ROOT_USER: z.string().min(1),
MINIO_REGION: z.string().min(1),
MINIO_BUCKET_NAME: z.string().min(1),
PORT: z.string().min(1),
DATABASE_URL: z.string().min(1),
});
export const env = envSchema.parse(process.env);

View File

@@ -0,0 +1,87 @@
import { LogoService } from "./logo.service";
import { AppService } from "./service";
import { MultipartFile } from "@fastify/multipart";
import { FastifyReply, FastifyRequest } from "fastify";
export class AppController {
private appService = new AppService();
private logoService = new LogoService();
async getAppInfo(request: FastifyRequest, reply: FastifyReply) {
try {
const appInfo = await this.appService.getAppInfo();
return reply.send(appInfo);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async getAllConfigs(request: FastifyRequest, reply: FastifyReply) {
try {
const configs = await this.appService.getAllConfigs();
return reply.send({ configs });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async updateConfig(request: FastifyRequest, reply: FastifyReply) {
try {
const { key } = request.params as { key: string };
const { value } = request.body as { value: string };
const config = await this.appService.updateConfig(key, value);
return reply.send({ config });
} catch (error: any) {
if (error.message === "Configuration not found") {
return reply.status(404).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
async bulkUpdateConfigs(request: FastifyRequest, reply: FastifyReply) {
try {
const updates = request.body as Array<{ key: string; value: string }>;
const configs = await this.appService.bulkUpdateConfigs(updates);
return reply.send({ configs });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async uploadLogo(request: FastifyRequest, reply: FastifyReply) {
try {
const file = (request.body as any).file as MultipartFile;
if (!file) {
return reply.status(400).send({ error: "No file uploaded" });
}
const buffer = await file.toBuffer();
const imageUrl = await this.logoService.uploadLogo(buffer);
// Get current logo URL to delete it
const currentLogo = await this.appService.updateConfig("appLogo", imageUrl);
if (currentLogo && currentLogo.value !== imageUrl) {
await this.logoService.deleteLogo(currentLogo.value);
}
return reply.send({ logo: imageUrl });
} catch (error: any) {
console.error("Upload error:", error);
return reply.status(400).send({ error: error.message });
}
}
async removeLogo(request: FastifyRequest, reply: FastifyReply) {
try {
const currentLogo = await this.appService.updateConfig("appLogo", "");
if (currentLogo && currentLogo.value) {
await this.logoService.deleteLogo(currentLogo.value);
}
return reply.send({ message: "Logo removed successfully" });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
export const UpdateConfigSchema = z.object({
key: z.string().describe("The config key"),
value: z.string().describe("The config value"),
});
export const BulkUpdateConfigSchema = z.array(
z.object({
key: z.string().describe("The config key"),
value: z.string().describe("The config value"),
})
);
export const ConfigResponseSchema = z.object({
key: z.string().describe("The config key"),
value: z.string().describe("The config value"),
type: z.string().describe("The config type"),
group: z.string().describe("The config group"),
updatedAt: z.date().describe("The config update date"),
});

View File

@@ -0,0 +1,64 @@
import { minioClient } from "../../config/minio.config";
import { randomUUID } from "crypto";
import sharp from "sharp";
export class LogoService {
private readonly bucketName = "logos";
constructor() {
this.initializeBucket();
}
private async initializeBucket() {
try {
const bucketExists = await minioClient.bucketExists(this.bucketName);
if (!bucketExists) {
await minioClient.makeBucket(this.bucketName, "sa-east-1");
const policy = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { AWS: ["*"] },
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${this.bucketName}/*`],
},
],
};
await minioClient.setBucketPolicy(this.bucketName, JSON.stringify(policy));
}
} catch (error) {
console.error("Error initializing logo bucket:", error);
}
}
async uploadLogo(imageBuffer: Buffer): Promise<string> {
try {
const metadata = await sharp(imageBuffer).metadata();
if (!metadata.width || !metadata.height) {
throw new Error("Invalid image file");
}
const webpBuffer = await sharp(imageBuffer).resize(256, 256, { fit: "contain" }).webp({ quality: 80 }).toBuffer();
const objectName = `app/${randomUUID()}.webp`;
await minioClient.putObject(this.bucketName, objectName, webpBuffer);
const publicUrl = `${process.env.MINIO_PUBLIC_URL}/${this.bucketName}/${objectName}`;
return publicUrl;
} catch (error) {
console.error("Error uploading logo:", error);
throw error;
}
}
async deleteLogo(imageUrl: string) {
try {
const objectName = imageUrl.split(`/${this.bucketName}/`)[1];
await minioClient.removeObject(this.bucketName, objectName);
} catch (error) {
console.error("Error deleting logo:", error);
throw error;
}
}
}

View File

@@ -0,0 +1,167 @@
import { AppController } from "./controller";
import { ConfigResponseSchema, BulkUpdateConfigSchema } from "./dto";
import { FastifyInstance } from "fastify";
import { z } from "zod";
export async function appRoutes(app: FastifyInstance) {
const appController = new AppController();
const adminPreValidation = async (request: any, reply: any) => {
try {
await request.jwtVerify();
if (!request.user.isAdmin) {
return reply.status(403).send({
error: "Access restricted to administrators",
});
}
} catch (err) {
console.error(err);
return reply.status(401).send({
error: ".",
});
}
};
app.get(
"/app/info",
{
schema: {
tags: ["App"],
operationId: "getAppInfo",
summary: "Get application base information",
description: "Get application base information",
response: {
200: z.object({
appName: z.string().describe("The application name"),
appDescription: z.string().describe("The application description"),
appLogo: z.string().describe("The application logo"),
}),
400: z.object({ error: z.string().describe("Error message") }),
},
},
},
appController.getAppInfo.bind(appController)
);
app.patch(
"/app/configs/:key",
{
preValidation: adminPreValidation,
schema: {
tags: ["App"],
operationId: "updateConfig",
summary: "Update a configuration value",
description: "Update a configuration value (admin only)",
params: z.object({
key: z.string().describe("The config key"),
}),
body: z.object({
value: z.string().describe("The config value"),
}),
response: {
200: z.object({
config: ConfigResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
appController.updateConfig.bind(appController)
);
app.get(
"/app/configs",
{
preValidation: adminPreValidation,
schema: {
tags: ["App"],
operationId: "getAllConfigs",
summary: "List all configurations",
description: "List all configurations (admin only)",
response: {
200: z.object({
configs: z.array(ConfigResponseSchema),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
appController.getAllConfigs.bind(appController)
);
app.patch(
"/app/configs",
{
preValidation: adminPreValidation,
schema: {
tags: ["App"],
operationId: "bulkUpdateConfigs",
summary: "Bulk update configuration values",
description: "Bulk update configuration values (admin only)",
body: BulkUpdateConfigSchema,
response: {
200: z.object({
configs: z.array(ConfigResponseSchema),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
appController.bulkUpdateConfigs.bind(appController)
);
app.post(
"/app/logo",
{
preValidation: adminPreValidation,
schema: {
tags: ["App"],
operationId: "uploadLogo",
summary: "Upload app logo",
description: "Upload a new app logo (admin only)",
consumes: ["multipart/form-data"],
body: z.object({
file: z.any().describe("Image file (JPG, PNG, GIF)"),
}),
response: {
200: z.object({
logo: z.string().describe("The logo URL"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
appController.uploadLogo.bind(appController)
);
app.delete(
"/app/logo",
{
preValidation: adminPreValidation,
schema: {
tags: ["App"],
operationId: "removeLogo",
summary: "Remove app logo",
description: "Remove the current app logo (admin only)",
response: {
200: z.object({
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
appController.removeLogo.bind(appController)
);
}

View File

@@ -0,0 +1,78 @@
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
export class AppService {
private configService = new ConfigService();
async getAppInfo() {
const [appName, appDescription, appLogo] = await Promise.all([
this.configService.getValue("appName"),
this.configService.getValue("appDescription"),
this.configService.getValue("appLogo"),
]);
return {
appName,
appDescription,
appLogo,
};
}
async getAllConfigs() {
return prisma.appConfig.findMany({
where: {
key: {
not: "jwtSecret",
},
},
orderBy: {
group: "asc",
},
});
}
async updateConfig(key: string, value: string) {
if (key === "jwtSecret") {
throw new Error("JWT Secret cannot be updated through this endpoint");
}
const config = await prisma.appConfig.findUnique({
where: { key },
});
if (!config) {
throw new Error("Configuration not found");
}
return prisma.appConfig.update({
where: { key },
data: { value },
});
}
async bulkUpdateConfigs(updates: Array<{ key: string; value: string }>) {
if (updates.some((update) => update.key === "jwtSecret")) {
throw new Error("JWT Secret cannot be updated through this endpoint");
}
const keys = updates.map((update) => update.key);
const existingConfigs = await prisma.appConfig.findMany({
where: { key: { in: keys } },
});
if (existingConfigs.length !== keys.length) {
const existingKeys = existingConfigs.map((config) => config.key);
const missingKeys = keys.filter((key) => !existingKeys.includes(key));
throw new Error(`Configurations not found: ${missingKeys.join(", ")}`);
}
return prisma.$transaction(
updates.map((update) =>
prisma.appConfig.update({
where: { key: update.key },
data: { value: update.value },
})
)
);
}
}

View File

@@ -0,0 +1,77 @@
import { LoginSchema, RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
import { AuthService } from "./service";
import { FastifyReply, FastifyRequest } from "fastify";
export class AuthController {
private authService = new AuthService();
async login(request: FastifyRequest, reply: FastifyReply) {
try {
const input = LoginSchema.parse(request.body);
const user = await this.authService.login(input);
const token = await request.jwtSign({
userId: user.id,
isAdmin: user.isAdmin,
});
// Set token in HTTP-only cookie
reply.setCookie("token", token, {
httpOnly: true,
path: "/",
secure: process.env.NODE_ENV === "production", // Only send over HTTPS in production
sameSite: "strict", // Protect against CSRF
});
// Return only user data
return reply.send({ user });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async logout(request: FastifyRequest, reply: FastifyReply) {
reply.clearCookie("token", { path: "/" });
return reply.send({ message: "Logout successful" });
}
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
try {
const { email } = RequestPasswordResetSchema.parse(request.body);
await this.authService.requestPasswordReset(email);
return reply.send({
message: "If an account exists with this email, a password reset link will be sent.",
});
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async resetPassword(request: FastifyRequest, reply: FastifyReply) {
try {
const schema = await createResetPasswordSchema();
const input = schema.parse(request.body);
await this.authService.resetPassword(input.token, input.password);
return reply.send({ message: "Password reset successfully" });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async getCurrentUser(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const user = await this.authService.getUserById(userId);
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
return reply.send({ user });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -0,0 +1,36 @@
import { ConfigService } from "../config/service";
import { z } from "zod";
const configService = new ConfigService();
export const createPasswordSchema = async () => {
const minLength = Number(await configService.getValue("passwordMinLength"));
return z.string().min(minLength, `Password must be at least ${minLength} characters`).describe("User password");
};
export const LoginSchema = z.object({
email: z.string().email("Invalid email").describe("User email"),
password: z.string().min(6, "Password must be at least 6 characters").describe("User password"),
});
export type LoginInput = z.infer<typeof LoginSchema>;
export const RequestPasswordResetSchema = z.object({
email: z.string().email("Invalid email").describe("User email"),
});
export const BaseResetPasswordSchema = z.object({
token: z.string().min(1, "Token is required").describe("Reset password token"),
});
export type BaseResetPasswordInput = z.infer<typeof BaseResetPasswordSchema>;
export const createResetPasswordSchema = async () => {
const minLength = Number(await configService.getValue("passwordMinLength"));
return BaseResetPasswordSchema.extend({
password: z.string().min(minLength, `Password must be at least ${minLength} characters`).describe("User password"),
});
};
export type ResetPasswordInput = BaseResetPasswordInput & {
password: string;
};

View File

@@ -0,0 +1,148 @@
import { ConfigService } from "../config/service";
import { validatePasswordMiddleware } from "../user/middleware";
import { AuthController } from "./controller";
import { RequestPasswordResetSchema, createResetPasswordSchema } from "./dto";
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { z } from "zod";
const configService = new ConfigService();
const createPasswordSchema = async () => {
const minLength = Number(await configService.getValue("passwordMinLength"));
return z.string().min(minLength, `Password must be at least ${minLength} characters`).describe("User password");
};
export async function authRoutes(app: FastifyInstance) {
const authController = new AuthController();
const passwordSchema = await createPasswordSchema();
const loginSchema = z.object({
email: z.string().email("Invalid email").describe("User email"),
password: passwordSchema,
});
app.post(
"/auth/login",
{
schema: {
tags: ["Authentication"],
operationId: "login",
summary: "Login",
description: "Performs login and returns user data",
body: loginSchema,
response: {
200: z.object({
user: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
}),
400: z.object({ error: z.string().describe("Error message") }),
},
},
},
authController.login.bind(authController)
);
app.post(
"/auth/logout",
{
schema: {
tags: ["Authentication"],
operationId: "logout",
summary: "Logout",
description: "Performs logout by clearing the token cookie",
response: {
200: z.object({ message: z.string().describe("Logout message") }),
},
},
},
authController.logout.bind(authController)
);
app.post(
"/auth/forgot-password",
{
schema: {
tags: ["Authentication"],
operationId: "requestPasswordReset",
summary: "Request Password Reset",
description: "Request password reset email",
body: RequestPasswordResetSchema,
response: {
200: z.object({
message: z.string().describe("Reset password email sent"),
}),
400: z.object({ error: z.string().describe("Error message") }),
},
},
},
authController.requestPasswordReset.bind(authController)
);
app.post(
"/auth/reset-password",
{
preValidation: validatePasswordMiddleware,
schema: {
tags: ["Authentication"],
operationId: "resetPassword",
summary: "Reset Password",
description: "Reset password using token",
body: await createResetPasswordSchema(),
response: {
200: z.object({
message: z.string().describe("Reset password message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
},
},
},
authController.resetPassword.bind(authController)
);
app.get(
"/auth/me",
{
schema: {
tags: ["Authentication"],
operationId: "getCurrentUser",
summary: "Get Current User",
description: "Returns the current authenticated user's information",
response: {
200: z.object({
user: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
}),
401: z.object({ error: z.string().describe("Error message") }),
},
},
preValidation: async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
},
},
authController.getCurrentUser.bind(authController)
);
}

View File

@@ -0,0 +1,206 @@
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import { EmailService } from "../email/service";
import { UserResponseSchema } from "../user/dto";
import { PrismaUserRepository } from "../user/repository";
import { LoginInput } from "./dto";
import bcrypt from "bcryptjs";
import crypto from "node:crypto";
export class AuthService {
private userRepository = new PrismaUserRepository();
private configService = new ConfigService();
private emailService = new EmailService();
async login(data: LoginInput) {
const user = await this.userRepository.findUserByEmail(data.email);
if (!user) {
throw new Error("Invalid credentials");
}
if (!user.isActive) {
throw new Error("Account is inactive. Please contact an administrator.");
}
const maxAttempts = Number(await this.configService.getValue("maxLoginAttempts"));
const blockDurationSeconds = Number(await this.configService.getValue("loginBlockDuration"));
const blockDuration = blockDurationSeconds * 1000;
const loginAttempt = await prisma.loginAttempt.findUnique({
where: { userId: user.id },
});
if (loginAttempt) {
if (loginAttempt.attempts >= maxAttempts && Date.now() - loginAttempt.lastAttempt.getTime() < blockDuration) {
const remainingTime = Math.ceil(
(blockDuration - (Date.now() - loginAttempt.lastAttempt.getTime())) / 1000 / 60
);
throw new Error(`Too many failed attempts. Please try again in ${remainingTime} minutes.`);
}
if (Date.now() - loginAttempt.lastAttempt.getTime() >= blockDuration) {
await prisma.loginAttempt.delete({
where: { userId: user.id },
});
}
}
const isValid = await bcrypt.compare(data.password, user.password);
if (!isValid) {
await prisma.loginAttempt.upsert({
where: { userId: user.id },
create: {
userId: user.id,
attempts: 1,
lastAttempt: new Date(),
},
update: {
attempts: {
increment: 1,
},
lastAttempt: new Date(),
},
});
throw new Error("Invalid credentials");
}
if (loginAttempt) {
await prisma.loginAttempt.delete({
where: { userId: user.id },
});
}
return UserResponseSchema.parse(user);
}
async validateLogin(email: string, password: string) {
const user = await prisma.user.findUnique({
where: { email },
include: { loginAttempts: true },
});
if (!user) {
throw new Error("Invalid credentials");
}
if (user.loginAttempts) {
const maxAttempts = Number(await this.configService.getValue("maxLoginAttempts"));
const blockDurationSeconds = Number(await this.configService.getValue("loginBlockDuration"));
const blockDuration = blockDurationSeconds * 1000; // convert seconds to milliseconds
if (
user.loginAttempts.attempts >= maxAttempts &&
Date.now() - user.loginAttempts.lastAttempt.getTime() < blockDuration
) {
const remainingTime = Math.ceil(
(blockDuration - (Date.now() - user.loginAttempts.lastAttempt.getTime())) / 1000 / 60
);
throw new Error(`Too many failed attempts. Please try again in ${remainingTime} minutes.`);
}
if (Date.now() - user.loginAttempts.lastAttempt.getTime() >= blockDuration) {
await prisma.loginAttempt.delete({
where: { userId: user.id },
});
}
}
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
await prisma.loginAttempt.upsert({
where: { userId: user.id },
create: {
userId: user.id,
attempts: 1,
lastAttempt: new Date(),
},
update: {
attempts: {
increment: 1,
},
lastAttempt: new Date(),
},
});
throw new Error("Invalid credentials");
}
if (user.loginAttempts) {
await prisma.loginAttempt.delete({
where: { userId: user.id },
});
}
return user;
}
async requestPasswordReset(email: string) {
const user = await this.userRepository.findUserByEmail(email);
if (!user) {
return;
}
const token = crypto.randomBytes(128).toString("hex");
const expirationSeconds = Number(await this.configService.getValue("passwordResetTokenExpiration"));
await prisma.passwordReset.create({
data: {
userId: user.id,
token,
expiresAt: new Date(Date.now() + expirationSeconds * 1000),
},
});
try {
await this.emailService.sendPasswordResetEmail(email, token);
} catch (error) {
console.error("Failed to send password reset email:", error);
throw new Error("Failed to send password reset email");
}
}
async resetPassword(token: string, newPassword: string) {
const resetRequest = await prisma.passwordReset.findFirst({
where: {
token,
used: false,
expiresAt: {
gt: new Date(),
},
},
include: {
user: true,
},
});
if (!resetRequest) {
throw new Error("Invalid or expired reset token");
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
await prisma.$transaction([
prisma.user.update({
where: { id: resetRequest.userId },
data: { password: hashedPassword },
}),
prisma.passwordReset.update({
where: { id: resetRequest.id },
data: { used: true },
}),
]);
}
async getUserById(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error("User not found");
}
return UserResponseSchema.parse(user);
}
}

View File

@@ -0,0 +1,42 @@
import { prisma } from "../../shared/prisma";
export class ConfigService {
async getValue(key: string): Promise<string> {
const config = await prisma.appConfig.findUnique({
where: { key },
});
if (!config) {
throw new Error(`Configuration ${key} not found`);
}
return config.value;
}
async getGroupConfigs(group: string) {
const configs = await prisma.appConfig.findMany({
where: { group },
});
return configs.reduce((acc, curr) => {
let value: any = curr.value;
switch (curr.type) {
case "number":
value = Number(value);
break;
case "boolean":
value = value === "true";
break;
case "json":
value = JSON.parse(value);
break;
case "bigint":
value = BigInt(value);
break;
}
return { ...acc, [curr.key]: value };
}, {});
}
}

View File

@@ -0,0 +1,77 @@
import { env } from "../../env";
import { ConfigService } from "../config/service";
import nodemailer from "nodemailer";
export class EmailService {
private configService = new ConfigService();
private async createTransporter() {
const smtpEnabled = await this.configService.getValue("smtpEnabled");
if (smtpEnabled !== "true") {
return null;
}
return nodemailer.createTransport({
host: await this.configService.getValue("smtpHost"),
port: Number(await this.configService.getValue("smtpPort")),
secure: false,
auth: {
user: await this.configService.getValue("smtpUser"),
pass: await this.configService.getValue("smtpPass"),
},
});
}
async sendPasswordResetEmail(to: string, resetToken: string) {
const transporter = await this.createTransporter();
if (!transporter) {
throw new Error("SMTP is not enabled");
}
const fromName = await this.configService.getValue("smtpFromName");
const fromEmail = await this.configService.getValue("smtpFromEmail");
const appName = await this.configService.getValue("appName");
await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to,
subject: `${appName} - Password Reset Request`,
html: `
<h1>${appName} - Password Reset Request</h1>
<p>Click the link below to reset your password:</p>
<a href="${env.FRONTEND_URL}/reset-password?token=${resetToken}">
Reset Password
</a>
<p>This link will expire in 1 hour.</p>
`,
});
}
async sendShareNotification(to: string, shareLink: string, shareName?: string) {
const transporter = await this.createTransporter();
if (!transporter) {
throw new Error("SMTP is not enabled");
}
const fromName = await this.configService.getValue("smtpFromName");
const fromEmail = await this.configService.getValue("smtpFromEmail");
const appName = await this.configService.getValue("appName");
const shareTitle = shareName || "Files";
await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to,
subject: `${appName} - ${shareTitle} shared with you`,
html: `
<h1>${appName} - Shared Files</h1>
<p>Someone has shared "${shareTitle}" with you.</p>
<p>Click the link below to access the shared files:</p>
<a href="${shareLink}">
Access Shared Files
</a>
<p>Note: This share may have an expiration date or view limit.</p>
`,
});
}
}

View File

@@ -0,0 +1,242 @@
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import { RegisterFileSchema, RegisterFileInput, UpdateFileSchema } from "./dto";
import { FileService } from "./service";
import { FastifyReply, FastifyRequest } from "fastify";
export class FileController {
private fileService = new FileService();
private configService = new ConfigService();
async getPresignedUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { filename, extension } = request.query as {
filename?: string;
extension?: string;
};
if (!filename || !extension) {
return reply.status(400).send({
error: "The 'filename' and 'extension' parameters are required.",
});
}
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const objectName = `${userId}/${Date.now()}-${filename}.${extension}`;
const expires = 3600;
const url = await this.fileService.getPresignedPutUrl(objectName, expires);
return reply.send({ url, objectName });
} catch (error) {
console.error("Error in getPresignedUrl:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
async registerFile(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const input: RegisterFileInput = RegisterFileSchema.parse(request.body);
const maxFileSize = BigInt(await this.configService.getValue("maxFileSize"));
if (BigInt(input.size) > maxFileSize) {
const maxSizeMB = Number(maxFileSize) / (1024 * 1024);
return reply.status(400).send({
error: `File size exceeds the maximum allowed size of ${maxSizeMB}MB`,
});
}
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
const currentStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
if (currentStorage + BigInt(input.size) > maxTotalStorage) {
const availableSpace = Number(maxTotalStorage - currentStorage) / (1024 * 1024);
return reply.status(400).send({
error: `Insufficient storage space. You have ${availableSpace.toFixed(2)}MB available`,
});
}
const fileRecord = await prisma.file.create({
data: {
name: input.name,
description: input.description,
extension: input.extension,
size: BigInt(input.size),
objectName: input.objectName,
userId,
},
});
const fileResponse = {
id: fileRecord.id,
name: fileRecord.name,
description: fileRecord.description,
extension: fileRecord.extension,
size: fileRecord.size.toString(),
objectName: fileRecord.objectName,
userId: fileRecord.userId,
createdAt: fileRecord.createdAt,
updatedAt: fileRecord.updatedAt,
};
return reply.status(201).send({
file: fileResponse,
message: "File registered successfully.",
});
} catch (error: any) {
console.error("Error in registerFile:", error);
return reply.status(400).send({ error: error.message });
}
}
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName: encodedObjectName } = request.params as {
objectName: string;
};
const objectName = decodeURIComponent(encodedObjectName);
if (!objectName) {
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
}
const fileRecord = await prisma.file.findFirst({ where: { objectName } });
if (!fileRecord) {
return reply.status(404).send({ error: "File not found." });
}
const expires = 3600;
const url = await this.fileService.getPresignedGetUrl(objectName, expires);
return reply.send({ url, expiresIn: expires });
} catch (error) {
console.error("Error in getDownloadUrl:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
async listFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const files = await prisma.file.findMany({
where: { userId },
});
const filesResponse = files.map((file) => ({
id: file.id,
name: file.name,
description: file.description,
extension: file.extension,
size: file.size.toString(),
objectName: file.objectName,
userId: file.userId,
createdAt: file.createdAt,
updatedAt: file.updatedAt,
}));
return reply.send({ files: filesResponse });
} catch (error) {
console.error("Error in listFiles:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
async deleteFile(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const { id } = request.params as { id: string };
if (!id) {
return reply.status(400).send({ error: "The 'id' parameter is required." });
}
const fileRecord = await prisma.file.findUnique({ where: { id } });
if (!fileRecord) {
return reply.status(404).send({ error: "File not found." });
}
const userId = (request as any).user?.userId;
if (fileRecord.userId !== userId) {
return reply.status(403).send({ error: "Access denied." });
}
await this.fileService.deleteObject(fileRecord.objectName);
await prisma.file.delete({ where: { id } });
return reply.send({ message: "File deleted successfully." });
} catch (error) {
console.error("Error in deleteFile:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
async updateFile(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const { id } = request.params as { id: string };
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({
error: "Unauthorized: a valid token is required to access this resource.",
});
}
const updateData = UpdateFileSchema.parse(request.body);
const fileRecord = await prisma.file.findUnique({ where: { id } });
if (!fileRecord) {
return reply.status(404).send({ error: "File not found." });
}
if (fileRecord.userId !== userId) {
return reply.status(403).send({ error: "Access denied." });
}
const updatedFile = await prisma.file.update({
where: { id },
data: updateData,
});
const fileResponse = {
id: updatedFile.id,
name: updatedFile.name,
description: updatedFile.description,
extension: updatedFile.extension,
size: updatedFile.size.toString(),
objectName: updatedFile.objectName,
userId: updatedFile.userId,
createdAt: updatedFile.createdAt,
updatedAt: updatedFile.updatedAt,
};
return reply.send({
file: fileResponse,
message: "File updated successfully.",
});
} catch (error: any) {
console.error("Error in updateFile:", error);
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -0,0 +1,21 @@
import { z } from "zod";
export const RegisterFileSchema = z.object({
name: z.string().min(1, "O nome do arquivo é obrigatório"),
description: z.string().optional(),
extension: z.string().min(1, "A extensão é obrigatória"),
size: z.number({
required_error: "O tamanho é obrigatório",
invalid_type_error: "O tamanho deve ser um número",
}),
objectName: z.string().min(1, "O objectName é obrigatório"),
});
export type RegisterFileInput = z.infer<typeof RegisterFileSchema>;
export const UpdateFileSchema = z.object({
name: z.string().optional().describe("The file name"),
description: z.string().optional().nullable().describe("The file description"),
});
export type UpdateFileInput = z.infer<typeof UpdateFileSchema>;

View File

@@ -0,0 +1,197 @@
import { FileController } from "./controller";
import { RegisterFileSchema, UpdateFileSchema } from "./dto";
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { z } from "zod";
export async function fileRoutes(app: FastifyInstance) {
const fileController = new FileController();
const preValidation = async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Token inválido ou ausente." });
}
};
app.get(
"/files/presigned-url",
{
preValidation,
schema: {
tags: ["File"],
operationId: "getPresignedUrl",
summary: "Get Presigned URL",
description: "Generates a pre-signed URL for direct upload to MinIO",
querystring: z.object({
filename: z.string().min(1, "The filename is required").describe("The filename of the file"),
extension: z.string().min(1, "The extension is required").describe("The extension of the file"),
}),
response: {
200: z.object({
url: z.string().describe("The pre-signed URL"),
objectName: z.string().describe("The object name of the file"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.getPresignedUrl.bind(fileController)
);
app.post(
"/files",
{
schema: {
tags: ["File"],
operationId: "registerFile",
summary: "Register File Metadata",
description: "Registers file metadata in the database",
body: RegisterFileSchema,
response: {
201: z.object({
file: z.object({
id: z.string().describe("The file ID"),
name: z.string().describe("The file name"),
description: z.string().nullable().describe("The file description"),
extension: z.string().describe("The file extension"),
size: z.string().describe("The file size"), // BigInt retornado como string
objectName: z.string().describe("The object name of the file"),
userId: z.string().describe("The user ID"),
createdAt: z.date().describe("The file creation date"),
updatedAt: z.date().describe("The file last update date"),
}),
message: z.string().describe("The file registration message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.registerFile.bind(fileController)
);
app.get(
"/files/:objectName/download",
{
schema: {
tags: ["File"],
operationId: "getDownloadUrl",
summary: "Get Download URL",
description: "Generates a pre-signed URL for downloading a private file",
params: z.object({
objectName: z.string().min(1, "The objectName is required"),
}),
response: {
200: z.object({
url: z.string().describe("The download URL"),
expiresIn: z.number().describe("The expiration time in seconds"),
}),
400: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.getDownloadUrl.bind(fileController)
);
app.get(
"/files",
{
preValidation,
schema: {
tags: ["File"],
operationId: "listFiles",
summary: "List Files",
description: "Lists user files",
response: {
200: z.object({
files: z.array(
z.object({
id: z.string().describe("The file ID"),
name: z.string().describe("The file name"),
description: z.string().nullable().describe("The file description"),
extension: z.string().describe("The file extension"),
size: z.string().describe("The file size"), // BigInt retornado como string
objectName: z.string().describe("The object name of the file"),
userId: z.string().describe("The user ID"),
createdAt: z.date().describe("The file creation date"),
updatedAt: z.date().describe("The file last update date"),
})
),
}),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.listFiles.bind(fileController)
);
app.delete(
"/files/:id",
{
preValidation,
schema: {
tags: ["File"],
operationId: "deleteFile",
summary: "Delete File",
description: "Deletes a user file",
params: z.object({
id: z.string().min(1, "The file id is required").describe("The file ID"),
}),
response: {
200: z.object({
message: z.string().describe("The file deletion message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.deleteFile.bind(fileController)
);
app.patch(
"/files/:id",
{
preValidation,
schema: {
tags: ["File"],
operationId: "updateFile",
summary: "Update File Metadata",
description: "Updates file metadata in the database",
params: z.object({
id: z.string().min(1, "The file id is required").describe("The file ID"),
}),
body: UpdateFileSchema,
response: {
200: z.object({
file: z.object({
id: z.string().describe("The file ID"),
name: z.string().describe("The file name"),
description: z.string().nullable().describe("The file description"),
extension: z.string().describe("The file extension"),
size: z.string().describe("The file size"),
objectName: z.string().describe("The object name of the file"),
userId: z.string().describe("The user ID"),
createdAt: z.date().describe("The file creation date"),
updatedAt: z.date().describe("The file last update date"),
}),
message: z.string().describe("Success message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.updateFile.bind(fileController)
);
}

View File

@@ -0,0 +1,51 @@
import { minioClient, bucketName } from "../../config/minio.config";
export class FileService {
getPresignedPutUrl(objectName: string, expires: number): Promise<string> {
return new Promise((resolve, reject) => {
(minioClient as any).presignedPutObject(bucketName, objectName, expires, {}, ((
err: Error | null,
presignedUrl?: string
) => {
if (err) {
console.error("Erro no presignedPutObject:", err);
reject(err);
} else if (!presignedUrl) {
reject(new Error("URL não gerada"));
} else {
resolve(presignedUrl);
}
}) as any);
});
}
getPresignedGetUrl(objectName: string, expires: number): Promise<string> {
return new Promise((resolve, reject) => {
(minioClient as any).presignedGetObject(bucketName, objectName, expires, ((
err: Error | null,
presignedUrl?: string
) => {
if (err) {
console.error("Erro no presignedGetObject:", err);
reject(err);
} else if (!presignedUrl) {
reject(new Error("URL não gerada"));
} else {
resolve(presignedUrl);
}
}) as any);
});
}
deleteObject(objectName: string): Promise<void> {
return new Promise((resolve, reject) => {
(minioClient as any).removeObject(bucketName, objectName, (err: Error | null) => {
if (err) {
console.error("Erro no removeObject:", err);
return reject(err);
}
resolve();
});
});
}
}

View File

@@ -0,0 +1,8 @@
export class HealthController {
async check() {
return {
status: "healthy",
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,26 @@
import { HealthController } from "./controller";
import { FastifyInstance } from "fastify";
import { z } from "zod";
export async function healthRoutes(app: FastifyInstance) {
const healthController = new HealthController();
app.get(
"/health",
{
schema: {
tags: ["Health"],
operationId: "checkHealth",
summary: "Check API Health",
description: "Returns the health status of the API",
response: {
200: z.object({
status: z.string().describe("The health status"),
timestamp: z.string().describe("The timestamp of the health check"),
}),
},
},
},
async () => healthController.check()
);
}

View File

@@ -0,0 +1,297 @@
import {
CreateShareSchema,
UpdateShareSchema,
UpdateSharePasswordSchema,
UpdateShareFilesSchema,
UpdateShareRecipientsSchema,
} from "./dto";
import { ShareService } from "./service";
import { FastifyReply, FastifyRequest } from "fastify";
export class ShareController {
private shareService = new ShareService();
async createShare(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const input = CreateShareSchema.parse(request.body);
const share = await this.shareService.createShare(input, userId);
return reply.status(201).send({ share });
} catch (error: any) {
console.error("Create Share Error:", error);
if (error.errors) {
return reply.status(400).send({ error: error.errors });
}
return reply.status(400).send({ error: error.message || "Unknown error occurred" });
}
}
async listUserShares(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const shares = await this.shareService.listUserShares(userId);
return reply.send({ shares });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async getShare(request: FastifyRequest, reply: FastifyReply) {
try {
const { shareId } = request.params as { shareId: string };
const { password } = request.query as { password?: string };
let userId: string | undefined;
try {
await request.jwtVerify();
userId = (request as any).user?.userId;
} catch (err) {
console.error(err);
}
const share = await this.shareService.getShare(shareId, password, userId);
return reply.send({ share });
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Share has reached maximum views") {
return reply.status(403).send({ error: error.message });
}
if (error.message === "Share has expired") {
return reply.status(410).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
async updateShare(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const { id, ...updateData } = UpdateShareSchema.parse(request.body);
const share = await this.shareService.updateShare(id, updateData, userId);
return reply.send({ share });
} catch (error: any) {
console.error("Update Share Error:", error);
return reply.status(400).send({ error: error.message });
}
}
async updatePassword(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { shareId } = request.params as { shareId: string };
const { password } = UpdateSharePasswordSchema.parse(request.body);
const share = await this.shareService.updateSharePassword(shareId, userId, password);
return reply.send({ share });
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Unauthorized to update this share") {
return reply.status(401).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
async addFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { shareId } = request.params as { shareId: string };
const { files } = UpdateShareFilesSchema.parse(request.body);
const share = await this.shareService.addFilesToShare(shareId, userId, files);
return reply.send({ share });
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Unauthorized to update this share") {
return reply.status(401).send({ error: error.message });
}
if (error.message.startsWith("Files not found:")) {
return reply.status(404).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
async removeFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { shareId } = request.params as { shareId: string };
const { files } = UpdateShareFilesSchema.parse(request.body);
const share = await this.shareService.removeFilesFromShare(shareId, userId, files);
return reply.send({ share });
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Unauthorized to update this share") {
return reply.status(401).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
async deleteShare(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { id } = request.params as { id: string };
const share = await this.shareService.findShareById(id);
if (!share) {
return reply.status(404).send({ error: "Share not found" });
}
if (share.creatorId !== userId) {
return reply.status(401).send({ error: "Unauthorized to delete this share" });
}
const deleted = await this.shareService.deleteShare(id);
return reply.send({ share: deleted });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async addRecipients(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { shareId } = request.params as { shareId: string };
const { emails } = UpdateShareRecipientsSchema.parse(request.body);
const share = await this.shareService.addRecipients(shareId, userId, emails);
return reply.send({ share });
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Unauthorized to update this share") {
return reply.status(401).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
async removeRecipients(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { shareId } = request.params as { shareId: string };
const { emails } = UpdateShareRecipientsSchema.parse(request.body);
const share = await this.shareService.removeRecipients(shareId, userId, emails);
return reply.send({ share });
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Unauthorized to update this share") {
return reply.status(401).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
async createOrUpdateAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { shareId } = request.params as { shareId: string };
const { alias } = request.body as { alias: string };
const userId = (request as any).user.userId;
const result = await this.shareService.createOrUpdateAlias(shareId, alias, userId);
return reply.send({ alias: result });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async getShareByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };
const { password } = request.query as { password?: string };
const share = await this.shareService.getShareByAlias(alias, password);
return reply.send({ share });
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
async notifyRecipients(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
const { shareId } = request.params as { shareId: string };
const { shareLink } = request.body as { shareLink: string };
const result = await this.shareService.notifyRecipients(shareId, userId, shareLink);
return reply.send(result);
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
if (error.message === "Unauthorized to access this share") {
return reply.status(401).send({ error: error.message });
}
if (error.message === "SMTP is not enabled") {
return reply.status(400).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -0,0 +1,97 @@
import { z } from "zod";
export const CreateShareSchema = z.object({
name: z.string().optional().describe("The share name"),
description: z.string().optional().describe("The share description"),
expiration: z
.string()
.datetime({
message: "Data de expiração deve estar no formato ISO 8601 (ex: 2025-02-06T13:20:49Z)",
})
.optional(),
files: z.array(z.string()).describe("The file IDs"),
password: z.string().optional().describe("The share password"),
maxViews: z.number().optional().nullable().describe("The maximum number of views"),
recipients: z.array(z.string().email()).optional().describe("The recipient emails"),
});
export const UpdateShareSchema = z.object({
id: z.string(),
name: z.string().optional(),
description: z.string().optional(),
expiration: z.string().datetime().optional(),
password: z.string().optional(),
maxViews: z.number().optional().nullable(),
recipients: z.array(z.string().email()).optional(),
});
export const ShareAliasResponseSchema = z.object({
id: z.string(),
alias: z.string(),
shareId: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});
export const ShareResponseSchema = z.object({
id: z.string().describe("The share ID"),
name: z.string().nullable().describe("The share name"),
description: z.string().nullable().describe("The share description"),
expiration: z.string().nullable().describe("The share expiration date"),
views: z.number().describe("The number of views"),
createdAt: z.string().describe("The share creation date"),
updatedAt: z.string().describe("The share update date"),
creatorId: z.string().describe("The creator ID"),
security: z.object({
maxViews: z.number().nullable().describe("The maximum number of views"),
hasPassword: z.boolean().describe("Whether the share has a password"),
}),
files: z.array(
z.object({
id: z.string().describe("The file ID"),
name: z.string().describe("The file name"),
description: z.string().nullable().describe("The file description"),
extension: z.string().describe("The file extension"),
size: z.string().describe("The file size"),
objectName: z.string().describe("The file object name"),
userId: z.string().describe("The user ID"),
createdAt: z.string().describe("The file creation date"),
updatedAt: z.string().describe("The file update date"),
})
),
recipients: z.array(
z.object({
id: z.string().describe("The recipient ID"),
email: z.string().email().describe("The recipient email"),
createdAt: z.string().describe("The recipient creation date"),
updatedAt: z.string().describe("The recipient update date"),
})
),
alias: ShareAliasResponseSchema.nullable(),
});
export const UpdateSharePasswordSchema = z.object({
password: z.string().nullable().describe("The new password. Send null to remove password"),
});
export const UpdateShareFilesSchema = z.object({
files: z.array(z.string().min(1, "File ID is required").describe("The file IDs")),
});
export const UpdateShareRecipientsSchema = z.object({
emails: z.array(z.string().email("Invalid email format").describe("The recipient emails")),
});
export const CreateShareAliasSchema = z.object({
shareId: z.string().describe("The share ID"),
alias: z
.string()
.regex(/^[a-zA-Z0-9]+$/, "Alias must contain only letters and numbers")
.min(3, "Alias must be at least 3 characters long")
.max(30, "Alias must not exceed 30 characters")
.describe("The custom alias for the share"),
});
export type CreateShareInput = z.infer<typeof CreateShareSchema>;
export type UpdateShareInput = z.infer<typeof UpdateShareSchema>;
export type ShareResponse = z.infer<typeof ShareResponseSchema>;

View File

@@ -0,0 +1,189 @@
import { prisma } from "../../shared/prisma";
import type { CreateShareInput } from "./dto";
import type { Share, ShareSecurity } from "@prisma/client";
export interface IShareRepository {
createShare(data: CreateShareInput & { securityId: string; creatorId: string }): Promise<Share>;
findShareById(id: string): Promise<
| (Share & {
security: ShareSecurity;
files: any[];
recipients: { email: string }[];
})
| null
>;
findShareBySecurityId(securityId: string): Promise<(Share & { security: ShareSecurity; files: any[] }) | null>;
updateShare(id: string, data: Partial<Share>): Promise<Share>;
updateShareSecurity(id: string, data: Partial<ShareSecurity>): Promise<ShareSecurity>;
deleteShare(id: string): Promise<Share>;
incrementViews(id: string): Promise<Share>;
addFilesToShare(shareId: string, fileIds: string[]): Promise<void>;
removeFilesFromShare(shareId: string, fileIds: string[]): Promise<void>;
findFilesByIds(fileIds: string[]): Promise<any[]>;
addRecipients(shareId: string, emails: string[]): Promise<void>;
removeRecipients(shareId: string, emails: string[]): Promise<void>;
findSharesByUserId(userId: string): Promise<Share[]>;
}
export class PrismaShareRepository implements IShareRepository {
async createShare(
data: Omit<CreateShareInput, "password" | "maxViews"> & { securityId: string; creatorId: string }
): Promise<Share> {
const { files, recipients, expiration, ...shareData } = data;
// Validate and filter arrays
const validFiles = (files ?? []).filter((id) => id && id.trim().length > 0);
const validRecipients = (recipients ?? []).filter((email) => email && email.trim().length > 0);
return prisma.share.create({
data: {
...shareData,
expiration: expiration ? new Date(expiration) : null,
files:
validFiles.length > 0
? {
connect: validFiles.map((id) => ({ id })),
}
: undefined,
recipients:
validRecipients?.length > 0
? {
create: validRecipients.map((email) => ({
email: email.trim().toLowerCase(),
})),
}
: undefined,
},
});
}
async findShareById(id: string) {
return prisma.share.findUnique({
where: { id },
include: {
security: true,
files: true,
recipients: true,
alias: true,
},
});
}
async findShareBySecurityId(securityId: string) {
return prisma.share.findUnique({
where: { securityId },
include: {
security: true,
files: true,
},
});
}
async updateShare(id: string, data: Partial<Share>): Promise<Share> {
return prisma.share.update({
where: { id },
data,
});
}
async updateShareSecurity(id: string, data: Partial<ShareSecurity>): Promise<ShareSecurity> {
return prisma.shareSecurity.update({
where: { id },
data,
});
}
async deleteShare(id: string): Promise<Share> {
return prisma.share.delete({
where: { id },
});
}
async incrementViews(id: string): Promise<Share> {
return prisma.share.update({
where: { id },
data: {
views: {
increment: 1,
},
},
});
}
async addFilesToShare(shareId: string, fileIds: string[]): Promise<void> {
await prisma.share.update({
where: { id: shareId },
data: {
files: {
connect: fileIds.map((id) => ({ id })),
},
},
});
}
async removeFilesFromShare(shareId: string, fileIds: string[]): Promise<void> {
await prisma.share.update({
where: { id: shareId },
data: {
files: {
disconnect: fileIds.map((id) => ({ id })),
},
},
});
}
async findFilesByIds(fileIds: string[]): Promise<any[]> {
return prisma.file.findMany({
where: {
id: {
in: fileIds,
},
},
});
}
async addRecipients(shareId: string, emails: string[]): Promise<void> {
await prisma.share.update({
where: { id: shareId },
data: {
recipients: {
create: emails.map((email) => ({
email,
})),
},
},
});
}
async removeRecipients(shareId: string, emails: string[]): Promise<void> {
await prisma.share.update({
where: { id: shareId },
data: {
recipients: {
deleteMany: {
email: {
in: emails,
},
},
},
},
});
}
async findSharesByUserId(userId: string) {
return prisma.share.findMany({
where: {
creatorId: userId,
},
include: {
security: true,
files: true,
recipients: true,
alias: true,
},
orderBy: {
createdAt: "desc",
},
});
}
}

View File

@@ -0,0 +1,349 @@
import { ShareController } from "./controller";
import {
CreateShareSchema,
ShareResponseSchema,
UpdateShareSchema,
UpdateSharePasswordSchema,
UpdateShareFilesSchema,
UpdateShareRecipientsSchema,
ShareAliasResponseSchema,
} from "./dto";
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { z } from "zod";
export async function shareRoutes(app: FastifyInstance) {
const shareController = new ShareController();
const preValidation = async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Token inválido ou ausente." });
}
};
app.post(
"/shares",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "createShare",
summary: "Create a new share",
description: "Create a new share",
body: CreateShareSchema,
response: {
201: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.createShare.bind(shareController)
);
app.get(
"/shares/me",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "listUserShares",
summary: "List all shares created by the authenticated user",
description: "List all shares created by the authenticated user",
response: {
200: z.object({
shares: z.array(ShareResponseSchema),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.listUserShares.bind(shareController)
);
app.get(
"/shares/:shareId",
{
schema: {
tags: ["Share"],
operationId: "getShare",
summary: "Get a share by ID",
description: "Get a share by ID",
params: z.object({
shareId: z.string().describe("The share ID"),
}),
querystring: z.object({
password: z.string().optional().describe("The share password"),
}),
response: {
200: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.getShare.bind(shareController)
);
app.put(
"/shares",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "updateShare",
summary: "Update a share",
description: "Update a share",
body: UpdateShareSchema,
response: {
200: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.updateShare.bind(shareController)
);
app.delete(
"/shares/:id",
{
schema: {
tags: ["Share"],
operationId: "deleteShare",
summary: "Delete a share",
description: "Delete a share",
params: z.object({
id: z.string().describe("The share ID"),
}),
response: {
200: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.deleteShare.bind(shareController)
);
app.patch(
"/shares/:shareId/password",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "updateSharePassword",
summary: "Update share password",
params: z.object({
shareId: z.string(),
}),
body: UpdateSharePasswordSchema,
response: {
200: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.updatePassword.bind(shareController)
);
app.post(
"/shares/:shareId/files",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "addFiles",
summary: "Add files to share",
params: z.object({
shareId: z.string().describe("The share ID"),
}),
body: UpdateShareFilesSchema,
response: {
200: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.addFiles.bind(shareController)
);
app.delete(
"/shares/:shareId/files",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "removeFiles",
summary: "Remove files from share",
params: z.object({
shareId: z.string().describe("The share ID"),
}),
body: UpdateShareFilesSchema,
response: {
200: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.removeFiles.bind(shareController)
);
app.post(
"/shares/:shareId/recipients",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "addRecipients",
summary: "Add recipients to a share",
params: z.object({
shareId: z.string().describe("The share ID"),
}),
body: UpdateShareRecipientsSchema,
response: {
200: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.addRecipients.bind(shareController)
);
app.delete(
"/shares/:shareId/recipients",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "removeRecipients",
summary: "Remove recipients from a share",
description: "Remove recipients from a share",
params: z.object({
shareId: z.string().describe("The share ID"),
}),
body: UpdateShareRecipientsSchema,
response: {
200: z.object({
share: ShareResponseSchema,
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
shareController.removeRecipients.bind(shareController)
);
app.post(
"/shares/:shareId/alias",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "createShareAlias",
summary: "Create or update share alias",
params: z.object({
shareId: z.string().describe("The share ID"),
}),
body: z.object({
alias: z
.string()
.regex(/^[a-zA-Z0-9]+$/, "Alias must contain only letters and numbers")
.min(3, "Alias must be at least 3 characters long")
.max(30, "Alias must not exceed 30 characters"),
}),
response: {
200: z.object({
alias: ShareAliasResponseSchema,
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
},
},
},
shareController.createOrUpdateAlias.bind(shareController)
);
app.get(
"/shares/alias/:alias",
{
schema: {
tags: ["Share"],
operationId: "getShareByAlias",
summary: "Get share by alias",
params: z.object({
alias: z.string().describe("The share alias"),
}),
querystring: z.object({
password: z.string().optional().describe("The share password"),
}),
response: {
200: z.object({
share: ShareResponseSchema,
}),
404: z.object({ error: z.string() }),
},
},
},
shareController.getShareByAlias.bind(shareController)
);
app.post(
"/shares/:shareId/notify",
{
preValidation,
schema: {
tags: ["Share"],
operationId: "notifyRecipients",
summary: "Send email notification to share recipients",
description: "Send email notification with share link to all recipients",
params: z.object({
shareId: z.string().describe("The share ID"),
}),
body: z.object({
shareLink: z.string().url().describe("The frontend share URL"),
}),
response: {
200: z.object({
message: z.string().describe("Success message"),
notifiedRecipients: z.array(z.string()).describe("List of notified email addresses"),
}),
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
},
},
},
shareController.notifyRecipients.bind(shareController)
);
}

View File

@@ -0,0 +1,360 @@
import { prisma } from "../../shared/prisma";
import { EmailService } from "../email/service";
import { CreateShareInput, UpdateShareInput, ShareResponseSchema } from "./dto";
import { PrismaShareRepository, IShareRepository } from "./repository";
import bcrypt from "bcryptjs";
export class ShareService {
constructor(private readonly shareRepository: IShareRepository = new PrismaShareRepository()) {}
private emailService = new EmailService();
private formatShareResponse(share: any) {
return {
...share,
createdAt: share.createdAt.toISOString(),
updatedAt: share.updatedAt.toISOString(),
expiration: share.expiration?.toISOString() || null,
alias: share.alias
? {
...share.alias,
createdAt: share.alias.createdAt.toISOString(),
updatedAt: share.alias.updatedAt.toISOString(),
}
: null,
security: {
maxViews: share.security.maxViews,
hasPassword: !!share.security.password,
},
files:
share.files?.map((file: any) => ({
...file,
size: file.size.toString(),
createdAt: file.createdAt.toISOString(),
updatedAt: file.updatedAt.toISOString(),
})) || [],
recipients:
share.recipients?.map((recipient: any) => ({
...recipient,
createdAt: recipient.createdAt.toISOString(),
updatedAt: recipient.updatedAt.toISOString(),
})) || [],
};
}
async createShare(data: CreateShareInput, userId: string) {
const security = await prisma.shareSecurity.create({
data: {
password: data.password,
maxViews: data.maxViews,
},
});
const { password, maxViews, ...shareData } = data;
const share = await this.shareRepository.createShare({
...shareData,
securityId: security.id,
creatorId: userId,
});
const shareWithRelations = await this.shareRepository.findShareById(share.id);
return ShareResponseSchema.parse(this.formatShareResponse(shareWithRelations));
}
async getShare(shareId: string, password?: string, userId?: string) {
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
}
if (userId && share.creatorId === userId) {
return ShareResponseSchema.parse(this.formatShareResponse(share));
}
if (share.expiration && new Date() > new Date(share.expiration)) {
throw new Error("Share has expired");
}
if (share.security?.maxViews && share.views >= share.security.maxViews) {
throw new Error("Share has reached maximum views");
}
if (share.security?.password && !password) {
throw new Error("Password required");
}
if (share.security?.password && password) {
const isPasswordValid = await bcrypt.compare(password, share.security.password);
if (!isPasswordValid) {
throw new Error("Invalid password");
}
}
await this.shareRepository.incrementViews(shareId);
const updatedShare = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updatedShare));
}
async updateShare(shareId: string, data: Omit<UpdateShareInput, "id">, userId: string) {
const { password, maxViews, recipients, ...shareData } = data;
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
}
if (share.creatorId !== userId) {
throw new Error("Unauthorized to update this share");
}
if (password || maxViews !== undefined) {
await this.shareRepository.updateShareSecurity(share.securityId, {
password: password ? await bcrypt.hash(password, 10) : undefined,
maxViews: maxViews,
});
}
if (recipients) {
await this.shareRepository.removeRecipients(
shareId,
share.recipients.map((r) => r.email)
);
if (recipients.length > 0) {
await this.shareRepository.addRecipients(shareId, recipients);
}
}
await this.shareRepository.updateShare(shareId, {
...shareData,
expiration: shareData.expiration ? new Date(shareData.expiration) : null,
});
const shareWithRelations = await this.shareRepository.findShareById(shareId);
return this.formatShareResponse(shareWithRelations);
}
async deleteShare(id: string) {
const share = await this.shareRepository.findShareById(id);
if (!share) {
throw new Error("Share not found");
}
const deleted = await prisma.$transaction(async (tx) => {
await tx.share.update({
where: { id },
data: {
files: {
set: [],
},
},
});
const deletedShare = await tx.share.delete({
where: { id },
include: {
security: true,
files: true,
},
});
if (deletedShare.security) {
await tx.shareSecurity.delete({
where: { id: deletedShare.security.id },
});
}
return deletedShare;
});
return ShareResponseSchema.parse(this.formatShareResponse(deleted));
}
async listUserShares(userId: string) {
const shares = await this.shareRepository.findSharesByUserId(userId);
return shares.map((share) => this.formatShareResponse(share));
}
async updateSharePassword(shareId: string, userId: string, password: string | null) {
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
}
if (share.creatorId !== userId) {
throw new Error("Unauthorized to update this share");
}
await this.shareRepository.updateShareSecurity(share.security.id, {
password: password ? await bcrypt.hash(password, 10) : null,
});
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
}
async addFilesToShare(shareId: string, userId: string, fileIds: string[]) {
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
}
if (share.creatorId !== userId) {
throw new Error("Unauthorized to update this share");
}
const existingFiles = await this.shareRepository.findFilesByIds(fileIds);
const notFoundFiles = fileIds.filter((id) => !existingFiles.some((file) => file.id === id));
if (notFoundFiles.length > 0) {
throw new Error(`Files not found: ${notFoundFiles.join(", ")}`);
}
await this.shareRepository.addFilesToShare(shareId, fileIds);
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
}
async removeFilesFromShare(shareId: string, userId: string, fileIds: string[]) {
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
}
if (share.creatorId !== userId) {
throw new Error("Unauthorized to update this share");
}
await this.shareRepository.removeFilesFromShare(shareId, fileIds);
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
}
async findShareById(id: string) {
const share = await this.shareRepository.findShareById(id);
if (!share) {
throw new Error("Share not found");
}
return share;
}
async addRecipients(shareId: string, userId: string, emails: string[]) {
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
}
if (share.creatorId !== userId) {
throw new Error("Unauthorized to update this share");
}
await this.shareRepository.addRecipients(shareId, emails);
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
}
async removeRecipients(shareId: string, userId: string, emails: string[]) {
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
}
if (share.creatorId !== userId) {
throw new Error("Unauthorized to update this share");
}
await this.shareRepository.removeRecipients(shareId, emails);
const updated = await this.shareRepository.findShareById(shareId);
return ShareResponseSchema.parse(this.formatShareResponse(updated));
}
async createOrUpdateAlias(shareId: string, alias: string, userId: string) {
const share = await this.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
}
if (share.creatorId !== userId) {
throw new Error("Unauthorized to update this share");
}
// Verifica se o alias já está em uso por outro share
const existingAlias = await prisma.shareAlias.findUnique({
where: { alias },
});
if (existingAlias && existingAlias.shareId !== shareId) {
throw new Error("Alias already in use");
}
// Cria ou atualiza o alias
const shareAlias = await prisma.shareAlias.upsert({
where: { shareId },
create: { shareId, alias },
update: { alias },
});
return {
...shareAlias,
createdAt: shareAlias.createdAt.toISOString(),
updatedAt: shareAlias.updatedAt.toISOString(),
};
}
async getShareByAlias(alias: string, password?: string) {
const shareAlias = await prisma.shareAlias.findUnique({
where: { alias },
include: {
share: {
include: {
security: true,
files: true,
recipients: true,
},
},
},
});
if (!shareAlias) {
throw new Error("Share not found");
}
// Reutiliza a lógica existente do getShare
return this.getShare(shareAlias.shareId, password);
}
async notifyRecipients(shareId: string, userId: string, shareLink: string) {
const share = await this.shareRepository.findShareById(shareId);
if (!share) {
throw new Error("Share not found");
}
if (share.creatorId !== userId) {
throw new Error("Unauthorized to access this share");
}
if (!share.recipients || share.recipients.length === 0) {
throw new Error("No recipients found for this share");
}
const notifiedRecipients: string[] = [];
for (const recipient of share.recipients) {
try {
await this.emailService.sendShareNotification(recipient.email, shareLink, share.name || undefined);
notifiedRecipients.push(recipient.email);
} catch (error) {
console.error(`Failed to send email to ${recipient.email}:`, error);
}
}
return {
message: `Successfully sent notifications to ${notifiedRecipients.length} recipients`,
notifiedRecipients,
};
}
}

View File

@@ -0,0 +1,55 @@
import { StorageService } from "./service";
import { FastifyRequest, FastifyReply } from "fastify";
export class StorageController {
private storageService = new StorageService();
async getDiskSpace(request: FastifyRequest, reply: FastifyReply) {
try {
let userId: string | undefined;
let isAdmin = false;
try {
await request.jwtVerify();
userId = (request as any).user?.userId;
isAdmin = (request as any).user?.isAdmin || false;
} catch (err) {
return reply.status(401).send({
error: "Unauthorized: a valid token is required to access this resource.",
});
}
const diskSpace = await this.storageService.getDiskSpace(userId, isAdmin);
return reply.send(diskSpace);
} catch (error: any) {
return reply.status(500).send({ error: error.message });
}
}
async checkUploadAllowed(request: FastifyRequest, reply: FastifyReply) {
try {
const { fileSize } = request.query as { fileSize: string };
let userId: string | undefined;
try {
await request.jwtVerify();
userId = (request as any).user?.userId;
} catch (err) {
return reply.status(401).send({
error: "Unauthorized: a valid token is required to access this resource.",
});
}
if (!fileSize) {
return reply.status(400).send({
error: "File size parameter is required (in bytes)",
});
}
const result = await this.storageService.checkUploadAllowed(Number(fileSize), userId);
return reply.send(result);
} catch (error: any) {
return reply.status(500).send({ error: error.message });
}
}
}

View File

@@ -0,0 +1,61 @@
import { StorageController } from "./controller";
import { FastifyInstance } from "fastify";
import { z } from "zod";
export async function storageRoutes(app: FastifyInstance) {
const storageController = new StorageController();
app.get(
"/storage/disk-space",
{
schema: {
tags: ["Storage"],
operationId: "getDiskSpace",
summary: "Get server disk space information",
description: "Get server disk space information",
response: {
200: z.object({
diskSizeGB: z.number().describe("The server disk size in GB"),
diskUsedGB: z.number().describe("The server disk used in GB"),
diskAvailableGB: z.number().describe("The server disk available in GB"),
uploadAllowed: z.boolean().describe("Whether file upload is allowed"),
}),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
storageController.getDiskSpace.bind(storageController)
);
app.get(
"/storage/check-upload",
{
schema: {
tags: ["Storage"],
operationId: "checkUploadAllowed",
summary: "Check if file upload is allowed",
description: "Check if file upload is allowed based on available space (fileSize in bytes)",
querystring: z.object({
fileSize: z.string().describe("The file size in bytes"),
}),
response: {
200: z.object({
diskSizeGB: z.number().describe("The server disk size in GB"),
diskUsedGB: z.number().describe("The server disk used in GB"),
diskAvailableGB: z.number().describe("The server disk available in GB"),
uploadAllowed: z.boolean().describe("Whether file upload is allowed"),
fileSizeInfo: z.object({
bytes: z.number(),
kb: z.number(),
mb: z.number(),
gb: z.number(),
}),
}),
400: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
storageController.checkUploadAllowed.bind(storageController)
);
}

View File

@@ -0,0 +1,113 @@
import { ConfigService } from "../config/service";
import { PrismaClient } from "@prisma/client";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
const prisma = new PrismaClient();
export class StorageService {
private configService = new ConfigService();
async getDiskSpace(
userId?: string,
isAdmin?: boolean
): Promise<{
diskSizeGB: number;
diskUsedGB: number;
diskAvailableGB: number;
uploadAllowed: boolean;
}> {
try {
if (isAdmin) {
// Original implementation for admins
const command = process.platform === "win32" ? "wmic logicaldisk get size,freespace,caption" : "df -B1 .";
const { stdout } = await execAsync(command);
let total = 0;
let available = 0;
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 {
const lines = stdout.trim().split("\n");
const [, size, , avail] = lines[1].trim().split(/\s+/);
total = parseInt(size);
available = parseInt(avail);
}
const used = total - 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,
};
} else if (userId) {
// Implementation for regular users
const maxTotalStorage = BigInt(await this.configService.getValue("maxTotalStoragePerUser"));
const maxStorageGB = Number(maxTotalStorage) / (1024 * 1024 * 1024);
// Busca apenas os arquivos que pertencem diretamente ao usuário
const userFiles = await prisma.file.findMany({
where: { userId },
select: { size: true },
});
// Calcula o total de espaço usado somando os arquivos
const totalUsedStorage = userFiles.reduce((acc, file) => acc + file.size, BigInt(0));
const usedStorageGB = Number(totalUsedStorage) / (1024 * 1024 * 1024);
const availableStorageGB = maxStorageGB - usedStorageGB;
return {
diskSizeGB: maxStorageGB,
diskUsedGB: usedStorageGB,
diskAvailableGB: availableStorageGB,
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");
}
}
async checkUploadAllowed(
fileSize: number,
userId?: string
): Promise<{
diskSizeGB: number;
diskUsedGB: number;
diskAvailableGB: number;
uploadAllowed: boolean;
fileSizeInfo: {
bytes: number;
kb: number;
mb: number;
gb: number;
};
}> {
const diskSpace = await this.getDiskSpace(userId);
const fileSizeGB = fileSize / (1024 * 1024 * 1024);
return {
...diskSpace,
uploadAllowed: diskSpace.diskAvailableGB > fileSizeGB,
fileSizeInfo: {
bytes: fileSize,
kb: Number((fileSize / 1024).toFixed(2)),
mb: Number((fileSize / (1024 * 1024)).toFixed(2)),
gb: Number((fileSize / (1024 * 1024 * 1024)).toFixed(2)),
},
};
}
}

View File

@@ -0,0 +1,80 @@
import { minioClient } from "../../config/minio.config";
import { PrismaClient } from "@prisma/client";
import { randomUUID } from "crypto";
import sharp from "sharp";
const prisma = new PrismaClient();
export class AvatarService {
private readonly bucketName = "avatars";
constructor() {
this.initializeBucket();
}
private async initializeBucket() {
try {
const bucketExists = await minioClient.bucketExists(this.bucketName);
if (!bucketExists) {
await minioClient.makeBucket(this.bucketName, "sa-east-1");
const policy = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { AWS: ["*"] },
Action: ["s3:GetObject"],
Resource: [`arn:aws:s3:::${this.bucketName}/*`],
},
],
};
await minioClient.setBucketPolicy(this.bucketName, JSON.stringify(policy));
}
} catch (error) {
console.error("Error initializing avatar bucket:", error);
}
}
async uploadAvatar(userId: string, imageBuffer: Buffer): Promise<string> {
try {
// Buscar usuário atual para verificar se tem avatar
const user = await prisma.user.findUnique({
where: { id: userId },
select: { image: true },
});
// Deletar avatar anterior se existir
if (user?.image) {
await this.deleteAvatar(user.image);
}
// Validar e fazer upload do novo avatar
const metadata = await sharp(imageBuffer).metadata();
if (!metadata.width || !metadata.height) {
throw new Error("Invalid image file");
}
const webpBuffer = await sharp(imageBuffer).resize(256, 256, { fit: "cover" }).webp({ quality: 80 }).toBuffer();
const objectName = `${userId}/${randomUUID()}.webp`;
await minioClient.putObject(this.bucketName, objectName, webpBuffer);
const publicUrl = `${process.env.MINIO_PUBLIC_URL}/${this.bucketName}/${objectName}`;
return publicUrl;
} catch (error) {
console.error("Error uploading avatar:", error);
throw error;
}
}
async deleteAvatar(imageUrl: string) {
try {
const objectName = imageUrl.split(`/${this.bucketName}/`)[1];
console.log("Deleting avatar:", objectName);
await minioClient.removeObject(this.bucketName, objectName);
} catch (error) {
console.error("Error deleting avatar:", error);
throw error;
}
}
}

View File

@@ -0,0 +1,134 @@
import { AvatarService } from "./avatar.service";
import { UpdateUserSchema, createRegisterUserSchema } from "./dto";
import { UserService } from "./service";
import { MultipartFile } from "@fastify/multipart";
import { FastifyReply, FastifyRequest } from "fastify";
export class UserController {
private userService = new UserService();
private avatarService = new AvatarService();
async register(request: FastifyRequest, reply: FastifyReply) {
try {
const schema = await createRegisterUserSchema();
const input = schema.parse(request.body);
const user = await this.userService.register(input);
return reply.status(201).send({ user, message: "User created successfully" });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async listUsers(request: FastifyRequest, reply: FastifyReply) {
try {
const users = await this.userService.listUsers();
return reply.send(users);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async getUserById(request: FastifyRequest, reply: FastifyReply) {
try {
const { id } = request.params as { id: string };
const user = await this.userService.getUserById(id);
return reply.send(user);
} catch (error: any) {
return reply.status(404).send({ error: error.message });
}
}
async updateUser(request: FastifyRequest, reply: FastifyReply) {
try {
const input = UpdateUserSchema.parse(request.body);
const { id, ...updateData } = input;
const updatedUser = await this.userService.updateUser(id, updateData);
return reply.send(updatedUser);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async activateUser(request: FastifyRequest, reply: FastifyReply) {
try {
const { id } = request.params as { id: string };
const user = await this.userService.activateUser(id);
return reply.send(user);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async deactivateUser(request: FastifyRequest, reply: FastifyReply) {
try {
const { id } = request.params as { id: string };
const user = await this.userService.deactivateUser(id);
return reply.send(user);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async deleteUser(request: FastifyRequest, reply: FastifyReply) {
try {
const { id } = request.params as { id: string };
const user = await this.userService.deleteUser(id);
return reply.send(user);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async updateUserImage(request: FastifyRequest, reply: FastifyReply) {
try {
const input = UpdateUserSchema.parse(request.body);
const { id, ...updateData } = input;
const updatedUser = await this.userService.updateUser(id, updateData);
return reply.send(updatedUser);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async uploadAvatar(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const file = (request.body as any).file as MultipartFile;
if (!file) {
return reply.status(400).send({ error: "No file uploaded" });
}
const buffer = await file.toBuffer();
const imageUrl = await this.avatarService.uploadAvatar(userId, buffer);
const updatedUser = await this.userService.updateUserImage(userId, imageUrl);
return reply.send(updatedUser);
} catch (error: any) {
console.error("Upload error:", error);
return reply.status(400).send({ error: error.message });
}
}
async removeAvatar(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const user = await this.userService.getUserById(userId);
if (user.image) {
await this.avatarService.deleteAvatar(user.image);
const updatedUser = await this.userService.updateUserImage(userId, null);
return reply.send(updatedUser);
}
return reply.send(user);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -0,0 +1,54 @@
import { ConfigService } from "../config/service";
import { z } from "zod";
const configService = new ConfigService();
export const BaseRegisterUserSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
username: z.string().min(3),
email: z.string().email(),
image: z.string().optional(),
isAdmin: z.boolean().optional().default(false),
});
export type BaseRegisterUserInput = z.infer<typeof BaseRegisterUserSchema>;
export const createRegisterUserSchema = async () => {
const minLength = Number(await configService.getValue("passwordMinLength"));
return BaseRegisterUserSchema.extend({
password: z.string().min(minLength, `Password must be at least ${minLength} characters`),
});
};
export type RegisterUserInput = BaseRegisterUserInput & {
password: string;
};
export const UpdateUserSchema = z.object({
id: z.string(),
firstName: z.string().min(1).optional(),
lastName: z.string().min(1).optional(),
username: z.string().min(3).optional(),
email: z.string().email().optional(),
image: z.string().optional(),
password: z.string().optional(),
isAdmin: z.boolean().optional(),
});
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export const UserResponseSchema = z.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
username: z.string(),
email: z.string(),
image: z.string().nullable(),
isAdmin: z.boolean(),
isActive: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type UserResponse = z.infer<typeof UserResponseSchema>;

View File

@@ -0,0 +1,17 @@
import { ConfigService } from "../config/service";
import { FastifyRequest, FastifyReply } from "fastify";
const configService = new ConfigService();
export async function validatePasswordMiddleware(request: FastifyRequest, reply: FastifyReply) {
const body = request.body as any;
if (!body.password) return;
const minLength = Number(await configService.getValue("passwordMinLength"));
if (body.password.length < minLength) {
return reply.status(400).send({
error: `Password must be at least ${minLength} characters long`,
});
}
}

View File

@@ -0,0 +1,72 @@
import { prisma } from "../../shared/prisma";
import type { RegisterUserInput, UpdateUserInput } from "./dto";
import type { User } from "@prisma/client";
export interface IUserRepository {
createUser(data: RegisterUserInput & { password: string }): Promise<User>;
findUserByEmail(email: string): Promise<User | null>;
findUserById(id: string): Promise<User | null>;
findUserByUsername(username: string): Promise<User | null>;
listUsers(): Promise<User[]>;
updateUser(data: UpdateUserInput & { password?: string }): Promise<User>;
deleteUser(id: string): Promise<User>;
activateUser(id: string): Promise<User>;
deactivateUser(id: string): Promise<User>;
}
export class PrismaUserRepository implements IUserRepository {
async createUser(data: RegisterUserInput & { password: string }): Promise<User> {
return prisma.user.create({
data: {
firstName: data.firstName,
lastName: data.lastName,
username: data.username,
email: data.email,
password: data.password,
image: data.image,
},
});
}
async findUserByEmail(email: string): Promise<User | null> {
return prisma.user.findUnique({ where: { email } });
}
async findUserById(id: string): Promise<User | null> {
return prisma.user.findUnique({ where: { id } });
}
async findUserByUsername(username: string): Promise<User | null> {
return prisma.user.findUnique({ where: { username } });
}
async listUsers(): Promise<User[]> {
return prisma.user.findMany();
}
async updateUser(data: UpdateUserInput & { password?: string }): Promise<User> {
const { id, ...rest } = data;
return prisma.user.update({
where: { id },
data: rest,
});
}
async deleteUser(id: string): Promise<User> {
return prisma.user.delete({ where: { id } });
}
async activateUser(id: string): Promise<User> {
return prisma.user.update({
where: { id },
data: { isActive: true },
});
}
async deactivateUser(id: string): Promise<User> {
return prisma.user.update({
where: { id },
data: { isActive: false },
});
}
}

View File

@@ -0,0 +1,359 @@
import { createPasswordSchema } from "../auth/dto";
import { UserController } from "./controller";
import { UpdateUserSchema, UserResponseSchema } from "./dto";
import { validatePasswordMiddleware } from "./middleware";
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { z } from "zod";
export async function userRoutes(app: FastifyInstance) {
const userController = new UserController();
const preValidation = async (request: any, reply: any) => {
try {
await request.jwtVerify();
if (!request.user.isAdmin) {
return reply
.status(403)
.send({ error: "Access restricted to administrators" })
.description("Access restricted to administrators");
}
} 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.");
}
};
const createRegisterSchema = async () => {
const passwordSchema = await createPasswordSchema();
return z.object({
firstName: z.string().min(1).describe("User first name"),
lastName: z.string().min(1).describe("User last name"),
username: z.string().min(3).describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().optional().describe("User profile image URL"),
password: passwordSchema.describe("User password"),
});
};
const createUpdateSchema = async () => {
const passwordSchema = await createPasswordSchema();
return UpdateUserSchema.extend({
password: passwordSchema.optional(),
});
};
app.post(
"/auth/register",
{
preValidation: [preValidation, validatePasswordMiddleware],
schema: {
tags: ["User"],
operationId: "registerUser",
summary: "Register New User",
description: "Register a new user (admin only)",
body: await createRegisterSchema(),
response: {
201: z.object({
user: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
message: z.string().describe("User registration message"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
userController.register.bind(userController)
);
app.get(
"/users",
{
preValidation,
schema: {
tags: ["User"],
operationId: "listUsers",
summary: "List All Users",
description: "List all users (admin only)",
response: {
200: z.array(
z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
})
),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
userController.listUsers.bind(userController)
);
app.get(
"/users/:id",
{
preValidation,
schema: {
tags: ["User"],
operationId: "getUserById",
summary: "Get User by ID",
description: "Get a user by ID (admin only)",
params: z.object({ id: z.string().describe("User ID") }),
response: {
200: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
404: z.object({ error: z.string().describe("Error message") }),
},
},
},
userController.getUserById.bind(userController)
);
app.put(
"/users",
{
preValidation,
schema: {
tags: ["User"],
operationId: "updateUser",
summary: "Update User Data",
description: "Update user data (admin only)",
body: await createUpdateSchema(),
response: {
200: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
userController.updateUser.bind(userController)
);
app.patch(
"/users/:id/activate",
{
preValidation,
schema: {
tags: ["User"],
operationId: "activateUser",
summary: "Activate User",
description: "Activate a user (admin only)",
params: z.object({ id: z.string().describe("User ID") }),
response: {
200: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
userController.activateUser.bind(userController)
);
app.patch(
"/users/:id/deactivate",
{
preValidation,
schema: {
tags: ["User"],
operationId: "deactivateUser",
summary: "Deactivate User",
description: "Deactivate a user (admin only)",
params: z.object({ id: z.string().describe("User ID") }),
response: {
200: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
userController.deactivateUser.bind(userController)
);
app.delete(
"/users/:id",
{
preValidation,
schema: {
tags: ["User"],
operationId: "deleteUser",
summary: "Delete User",
description: "Delete a user (admin only)",
params: z.object({ id: z.string().describe("User ID") }),
response: {
200: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
userController.deleteUser.bind(userController)
);
app.patch(
"/users/:id/image",
{
preValidation,
schema: {
tags: ["User"],
operationId: "updateUserImage",
summary: "Update User Image",
description: "Update user profile image (admin only)",
params: z.object({ id: z.string().describe("User ID") }),
body: z.object({
image: z.string().url().describe("User profile image URL"),
}),
response: {
200: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
image: z.string().nullable().describe("User profile image URL"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message") }),
},
},
},
userController.updateUserImage.bind(userController)
);
app.post(
"/users/avatar",
{
preValidation,
schema: {
tags: ["User"],
operationId: "uploadAvatar",
summary: "Upload user avatar",
description: "Upload and update user profile image",
consumes: ["multipart/form-data"],
body: z.object({
file: z.any().describe("Image file (JPG, PNG, GIF)"),
}),
response: {
200: UserResponseSchema,
400: z.object({ error: z.string() }),
401: z.object({ error: z.string() }),
},
},
},
userController.uploadAvatar.bind(userController)
);
app.delete(
"/users/avatar",
{
preValidation: async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Unauthorized" });
}
},
schema: {
tags: ["User"],
operationId: "removeAvatar",
summary: "Remove user avatar",
description: "Remove user profile image",
response: {
200: UserResponseSchema,
401: z.object({ error: z.string() }),
},
},
},
userController.removeAvatar.bind(userController)
);
}

View File

@@ -0,0 +1,95 @@
import { RegisterUserInput, UserResponseSchema } from "./dto";
import { PrismaUserRepository, IUserRepository } from "./repository";
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
type UserWithPassword = {
id: string;
email?: string;
firstName?: string;
lastName?: string;
username?: string;
password?: string;
};
const prisma = new PrismaClient();
export class UserService {
constructor(private readonly userRepository: IUserRepository = new PrismaUserRepository()) {}
async register(data: RegisterUserInput) {
const existingUser = await this.userRepository.findUserByEmail(data.email);
const existingUsername = await this.userRepository.findUserByUsername(data.username);
if (existingUser) {
throw new Error("User with this email already exists");
}
if (existingUsername) {
throw new Error("User with this username already exists");
}
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await this.userRepository.createUser({
...data,
password: hashedPassword,
});
return UserResponseSchema.parse(user);
}
async listUsers() {
const users = await this.userRepository.listUsers();
return users.map((user) => UserResponseSchema.parse(user));
}
async getUserById(id: string) {
const user = await this.userRepository.findUserById(id);
if (!user) {
throw new Error("User not found");
}
return UserResponseSchema.parse(user);
}
async updateUser(userId: string, data: Partial<UserWithPassword>) {
const { password, ...rest } = data;
const updateData: any = { ...rest };
if (password) {
updateData.password = await bcrypt.hash(password, 10);
}
const user = await this.userRepository.updateUser({
id: userId,
...updateData,
});
return UserResponseSchema.parse(user);
}
async deleteUser(id: string) {
const deleted = await this.userRepository.deleteUser(id);
return UserResponseSchema.parse(deleted);
}
async activateUser(id: string) {
const user = await this.userRepository.activateUser(id);
return UserResponseSchema.parse(user);
}
async deactivateUser(id: string) {
const user = await this.userRepository.deactivateUser(id);
return UserResponseSchema.parse(user);
}
async updateUserImage(userId: string, imageUrl: string | null) {
const user = await prisma.user.update({
where: { id: userId },
data: {
image: imageUrl,
updatedAt: new Date(),
},
});
return user;
}
}

44
apps/server/src/server.ts Normal file
View File

@@ -0,0 +1,44 @@
import { buildApp } from "./app";
import { env } from "./env";
import { appRoutes } from "./modules/app/routes";
import { authRoutes } from "./modules/auth/routes";
import { fileRoutes } from "./modules/file/routes";
import { healthRoutes } from "./modules/health/routes";
import { shareRoutes } from "./modules/share/routes";
import { storageRoutes } from "./modules/storage/routes";
import { userRoutes } from "./modules/user/routes";
import fastifyMultipart from "@fastify/multipart";
async function startServer() {
const app = await buildApp();
await app.register(fastifyMultipart, {
limits: {
fileSize: 5 * 1024 * 1024,
},
attachFieldsToBody: true,
});
app.register(authRoutes);
app.register(userRoutes);
app.register(fileRoutes);
app.register(shareRoutes);
app.register(storageRoutes);
app.register(appRoutes);
app.register(healthRoutes);
await app.listen({
port: Number(env.PORT),
host: "0.0.0.0",
});
console.log(`🌴 Palmr server running on port ${env.PORT} 🌴`);
console.log("\n📚 API Documentation:");
console.log(` - API Reference: http://localhost:${env.PORT}/docs\n`);
}
startServer().catch((error) => {
console.error("Error starting server:", error);
process.exit(1);
});

View File

@@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export { prisma };

14
apps/server/src/types/fastify.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { FastifyRequest } from "fastify";
declare module "fastify" {
interface FastifyRequest {
/**
* Método decorado para assinar um payload JWT.
* @param payload - Objeto que será assinado.
* @param options - Opções adicionais para a assinatura.
* @returns O token JWT assinado.
*/
jwtSign(payload: object, options?: object): string;
}
}

27
apps/server/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 22",
"_version": "22.0.0",
"compilerOptions": {
"lib": [
"es2023"
],
"module": "node16",
"target": "es2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"moduleResolution": "node16",
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./src",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*"
]
}

1
apps/web/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3333

1
apps/web/.env.example Normal file
View File

@@ -0,0 +1 @@
# VITE_API_URL=http://localhost:3333

20
apps/web/.eslintignore Normal file
View File

@@ -0,0 +1,20 @@
.now/*
*.css
.changeset
dist
esm/*
public/*
tests/*
scripts/*
*.config.js
.DS_Store
node_modules
coverage
.next
build
!.commitlintrc.cjs
!.lintstagedrc.cjs
!jest.config.js
!plopfile.js
!react-shim.js
!tsup.config.ts

120
apps/web/.eslintrc.json Normal file
View File

@@ -0,0 +1,120 @@
{
"$schema": "https://json.schemastore.org/eslintrc.json",
"env": {
"browser": false,
"es2021": true,
"node": true
},
"extends": [
"plugin:react/recommended",
"plugin:prettier/recommended",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended"
],
"plugins": [
"react",
"unused-imports",
"import",
"@typescript-eslint",
"jsx-a11y",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"no-console": "off",
"react/prop-types": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/interactive-supports-focus": "off",
"jsx-a11y/no-static-element-interactions": "off",
"prettier/prettier": "warn",
"no-unused-vars": "off",
"unused-imports/no-unused-vars": "off",
"unused-imports/no-unused-imports": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"args": "after-used",
"ignoreRestSiblings": false,
"argsIgnorePattern": "^_.*?$"
}
],
"import/order": [
"off",
{
"groups": [
"type",
"builtin",
"object",
"external",
"internal",
"parent",
"sibling",
"index"
],
"pathGroups": [
{
"pattern": "~/**",
"group": "external",
"position": "after"
}
],
"newlines-between": "always"
}
],
"react/self-closing-comp": "warn",
"react/jsx-sort-props": [
"warn",
{
"callbacksLast": true,
"shorthandFirst": true,
"noSortAlphabetically": false,
"reservedFirst": true
}
],
"padding-line-between-statements": [
"warn",
{
"blankLine": "always",
"prev": "*",
"next": "return"
},
{
"blankLine": "always",
"prev": [
"const",
"let",
"var"
],
"next": "*"
},
{
"blankLine": "any",
"prev": [
"const",
"let",
"var"
],
"next": [
"const",
"let",
"var"
]
}
]
}
}

30
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
pnpm-lock.yaml
yarn.lock
package-lock.json
bun.lockb

2
apps/web/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
public-hoist-pattern[]=*@heroui/*
package-lock=true

View File

@@ -0,0 +1,7 @@
{
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"printWidth": 120,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}

20
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:18-alpine AS builder
WORKDIR /app/web
RUN npm install -g pnpm
COPY package.json .
COPY pnpm-lock.yaml .
RUN pnpm install
COPY . .
RUN pnpm build
EXPOSE 4173
ENV VITE_ROUTER_MODE=history
CMD ["pnpm", "preview", "--host"]

1
apps/web/README.md Normal file
View File

@@ -0,0 +1 @@
# 🌴 Palmr

BIN
apps/web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

36
apps/web/index.html Normal file
View File

@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Palmr.</title>
<meta key="title" content="🌴 Palmr." property="og:title" />
<meta content="Palmr. - Easy document share." property="og:description" />
<meta content="Palmr. - Easy document share." name="description" />
<meta
key="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
name="viewport"
/>
<link href="/favicon.ico" rel="icon" />
<script>
(function() {
try {
const theme = localStorage.getItem('theme') || 'light';
document.documentElement.classList.add(theme);
} catch (e) {
console.error('Failed to initialize theme:', e);
}
})();
</script>
<script>
window.env = {
VITE_API_URL: '__VITE_API_URL__'
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

10
apps/web/orval.config.ts Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
"palmr-file": {
input: "./routes.json",
output: {
mode: "single",
target: "./src/http/endpoints/palmrAPI.ts",
schemas: "./src/http/models",
},
},
};

83
apps/web/package.json Normal file
View File

@@ -0,0 +1,83 @@
{
"name": "vite-template",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint -c .eslintrc.json ./src/**/**/*.{ts,tsx} --fix",
"preview": "vite preview",
"format": "prettier --write ."
},
"dependencies": {
"@heroui/avatar": "^2.2.7",
"@heroui/badge": "^2.2.6",
"@heroui/breadcrumbs": "^2.2.7",
"@heroui/button": "2.2.10",
"@heroui/card": "^2.2.10",
"@heroui/chip": "^2.2.7",
"@heroui/code": "2.2.7",
"@heroui/divider": "^2.2.6",
"@heroui/dropdown": "2.3.10",
"@heroui/image": "^2.2.6",
"@heroui/input": "2.4.10",
"@heroui/kbd": "2.2.7",
"@heroui/link": "2.2.8",
"@heroui/modal": "^2.2.8",
"@heroui/navbar": "2.2.9",
"@heroui/progress": "^2.2.7",
"@heroui/select": "^2.4.10",
"@heroui/snippet": "2.2.11",
"@heroui/switch": "2.2.9",
"@heroui/system": "2.4.7",
"@heroui/table": "^2.2.9",
"@heroui/theme": "2.4.6",
"@hookform/resolvers": "^4.0.0",
"@react-aria/visually-hidden": "3.8.19",
"@react-types/shared": "3.27.0",
"axios": "^1.7.9",
"clsx": "2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "12.4.1",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.3",
"nanoid": "^5.0.9",
"react": "18.3.1",
"react-country-flag": "^3.1.0",
"react-dom": "18.3.1",
"react-hook-form": "^7.54.2",
"react-i18next": "^15.4.1",
"react-icons": "^5.4.0",
"react-router-dom": "6.23.0",
"sonner": "^1.7.4",
"tailwind-variants": "0.3.1",
"tailwindcss": "3.4.16",
"zod": "^3.24.1"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/node": "22.13.1",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.23.0",
"@typescript-eslint/parser": "8.23.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "10.4.20",
"eslint": "^8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.2.3",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-unused-imports": "4.1.4",
"orval": "^7.5.0",
"postcss": "8.5.1",
"prettier": "3.4.2",
"typescript": "5.7.3",
"vite": "^6.1.0",
"vite-tsconfig-paths": "^4.3.2"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

1
apps/web/routes.json Normal file

File diff suppressed because one or more lines are too long

85
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,85 @@
import { FilesPage } from "./pages/files/page";
import { ForgotPasswordPage } from "./pages/forgot-password/page";
import { ResetPasswordPage } from "./pages/reset-password/page";
import { SettingsPage } from "./pages/settings/page";
import { PublicSharePage } from "./pages/share/[alias]/page";
import { SharesPage } from "./pages/shares/page";
import { AdminProtectedRoute } from "@/components/route-protector/admin-protected-route";
import { ProtectedRoute } from "@/components/route-protector/protected-route";
import { DashboardPage } from "@/pages/dashboard/page";
import { HomePage } from "@/pages/home/page";
import { LoginPage } from "@/pages/login/page";
import { ProfilePage } from "@/pages/profile/page";
import { AdminAreaPage } from "@/pages/users-management/page";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Route, Routes } from "react-router-dom";
function App() {
const { i18n } = useTranslation();
useEffect(() => {
document.documentElement.dir = i18n.language === "ar-SA" ? "rtl" : "ltr";
document.documentElement.lang = i18n.language;
}, [i18n.language]);
return (
<Routes>
<Route element={<HomePage />} path="/" />
<Route element={<LoginPage />} path="/login" />
<Route
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
path="/dashboard"
/>
<Route
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
path="/profile"
/>
<Route
element={
<AdminProtectedRoute>
<SettingsPage />
</AdminProtectedRoute>
}
path="/settings"
/>
<Route
element={
<AdminProtectedRoute>
<AdminAreaPage />
</AdminProtectedRoute>
}
path="/admin"
/>
<Route
element={
<ProtectedRoute>
<FilesPage />
</ProtectedRoute>
}
path="/files"
/>
<Route
element={
<ProtectedRoute>
<SharesPage />
</ProtectedRoute>
}
path="/shares"
/>
<Route element={<PublicSharePage />} path="/s/:alias" />
<Route element={<ForgotPasswordPage />} path="/forgot-password" />
<Route element={<ResetPasswordPage />} path="/reset-password" />
</Routes>
);
}
export default App;

View File

@@ -0,0 +1,178 @@
import { addFiles, listFiles, removeFiles } from "@/http/endpoints";
import { Button } from "@heroui/button";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaArrowLeft, FaArrowRight, FaFile } from "react-icons/fa";
import { toast } from "sonner";
interface FileSelectorProps {
shareId: string;
selectedFiles: string[];
onSave: (files: string[]) => Promise<void>;
}
export function FileSelector({ shareId, selectedFiles, onSave }: FileSelectorProps) {
const { t } = useTranslation();
const [availableFiles, setAvailableFiles] = useState<any[]>([]);
const [shareFiles, setShareFiles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [availableFilter, setAvailableFilter] = useState("");
const [shareFilter, setShareFilter] = useState("");
useEffect(() => {
loadFiles();
}, [shareId, selectedFiles]);
const loadFiles = async () => {
try {
const response = await listFiles();
const allFiles = response.data.files || [];
setShareFiles(allFiles.filter((file) => selectedFiles.includes(file.id)));
setAvailableFiles(allFiles.filter((file) => !selectedFiles.includes(file.id)));
} catch (error) {
console.error(error);
toast.error("Failed to load files");
}
};
const moveToShare = (fileId: string) => {
const file = availableFiles.find((f) => f.id === fileId);
if (file) {
setShareFiles([...shareFiles, file]);
setAvailableFiles(availableFiles.filter((f) => f.id !== fileId));
}
};
const removeFromShare = (fileId: string) => {
const file = shareFiles.find((f) => f.id === fileId);
if (file) {
setAvailableFiles([...availableFiles, file]);
setShareFiles(shareFiles.filter((f) => f.id !== fileId));
}
};
const handleSave = async () => {
try {
setIsLoading(true);
const filesToAdd = shareFiles.filter((file) => !selectedFiles.includes(file.id)).map((file) => file.id);
const filesToRemove = selectedFiles.filter((fileId) => !shareFiles.find((f) => f.id === fileId));
if (filesToAdd.length > 0) {
await addFiles(shareId, { files: filesToAdd });
}
if (filesToRemove.length > 0) {
await removeFiles(shareId, { files: filesToRemove });
}
await onSave(shareFiles.map((f) => f.id));
toast.success("Files updated successfully");
} catch (error) {
console.error(error);
toast.error("Failed to update files");
} finally {
setIsLoading(false);
}
};
const filteredAvailableFiles = availableFiles.filter((file) =>
file.name.toLowerCase().includes(availableFilter.toLowerCase())
);
const filteredShareFiles = shareFiles.filter((file) => file.name.toLowerCase().includes(shareFilter.toLowerCase()));
return (
<div className="flex flex-col gap-4">
<div className="flex gap-4 h-[500px]">
<div className="flex-1 border rounded-lg">
<div className="p-4 border-b">
<h3 className="font-medium">
{t("fileSelector.availableFiles", { count: filteredAvailableFiles.length })}
</h3>
<input
className="mt-2 w-full px-3 py-2 rounded-lg"
placeholder={t("fileSelector.searchPlaceholder")}
type="search"
value={availableFilter}
onChange={(e) => setAvailableFilter(e.target.value)}
/>
</div>
<div className="p-4 h-[calc(100%-115px)] overflow-y-auto">
{filteredAvailableFiles.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{availableFilter ? t("fileSelector.noMatchingFiles") : t("fileSelector.noAvailableFiles")}
</div>
) : (
<div className="flex flex-col gap-2">
{filteredAvailableFiles.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border border-transparent hover:border-primary-500 cursor-pointer"
onClick={() => moveToShare(file.id)}
>
<div className="flex items-center gap-2">
<FaFile className="text-gray-400" />
<span className="truncate max-w-[150px]" title={file.name}>
{file.name}
</span>
</div>
<FaArrowRight className="text-gray-400" />
</div>
))}
</div>
)}
</div>
</div>
<div className="flex-1 border rounded-lg">
<div className="p-4 border-b">
<h3 className="font-medium">{t("fileSelector.shareFiles", { count: filteredShareFiles.length })}</h3>
<input
className="mt-2 w-full px-3 py-2 rounded-lg"
placeholder={t("fileSelector.searchPlaceholder")}
type="search"
value={shareFilter}
onChange={(e) => setShareFilter(e.target.value)}
/>
</div>
<div className="p-4 h-[calc(100%-115px)] overflow-y-auto">
{filteredShareFiles.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{shareFilter ? t("fileSelector.noMatchingFiles") : t("fileSelector.noFilesInShare")}
</div>
) : (
<div className="flex flex-col gap-2">
{filteredShareFiles.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border border-transparent hover:border-primary-500 cursor-pointer"
onClick={() => removeFromShare(file.id)}
>
<div className="flex items-center gap-2">
<FaFile className="text-gray-400" />
<span className="truncate max-w-[150px]" title={file.name}>
{file.name}
</span>
</div>
<FaArrowLeft className="text-gray-400" />
</div>
))}
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end">
<Button color="primary" isLoading={isLoading} onPress={handleSave}>
{t("fileSelector.saveChanges")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Button } from "@heroui/button";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from "@heroui/dropdown";
import ReactCountryFlag from "react-country-flag";
import { useTranslation } from "react-i18next";
import { FaGlobe } from "react-icons/fa";
const languages = {
"en-US": "English",
"pt-BR": "Português",
"fr-FR": "Français",
"es-ES": "Español",
"de-DE": "Deutsch",
"tr-TR": "Türkçe (Turkish)",
"ru-RU": "Русский (Russian)",
"hi-IN": "हिन्दी (Hindi)",
"ar-SA": "العربية (Arabic)",
"zh-CN": "中文 (Chinese)",
"ja-JP": "日本語 (Japanese)",
"ko-KR": "한국어 (Korean)",
};
export function LanguageSwitcher() {
const { i18n } = useTranslation();
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
};
return (
<Dropdown>
<DropdownTrigger>
<Button isIconOnly className="text-default-500" size="sm" variant="light">
<FaGlobe size={15} />
</Button>
</DropdownTrigger>
<DropdownMenu selectedKeys={[i18n.language]} selectionMode="single">
{Object.entries(languages).map(([code, name]) => (
<DropdownItem key={code} onClick={() => changeLanguage(code)}>
<ReactCountryFlag
svg
countryCode={code.split("-")[1]}
style={{
marginRight: "8px",
width: "1em",
height: "1em",
}}
/>
{name}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
);
}

View File

@@ -0,0 +1,133 @@
import { useShareContext } from "@/contexts/ShareContext";
import { addRecipients, removeRecipients, notifyRecipients } from "@/http/endpoints";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FaPlus, FaTrash, FaEnvelope, FaBell } from "react-icons/fa";
import { toast } from "sonner";
interface Recipient {
id: string;
email: string;
createdAt: string;
updatedAt: string;
}
interface RecipientSelectorProps {
shareId: string;
selectedRecipients: Recipient[];
shareAlias?: string;
onSuccess: () => void;
}
export function RecipientSelector({ shareId, selectedRecipients, shareAlias, onSuccess }: RecipientSelectorProps) {
const { t } = useTranslation();
const { smtpEnabled } = useShareContext();
const [recipients, setRecipients] = useState<string[]>(selectedRecipients?.map((recipient) => recipient.email) || []);
const [newRecipient, setNewRecipient] = useState("");
useEffect(() => {
setRecipients(selectedRecipients?.map((recipient) => recipient.email) || []);
}, [selectedRecipients]);
const handleAddRecipient = () => {
if (newRecipient && !recipients.includes(newRecipient)) {
addRecipients(shareId, { emails: [newRecipient] })
.then(() => {
setRecipients([...recipients, newRecipient]);
setNewRecipient("");
toast.success(t("recipientSelector.addSuccess"));
onSuccess();
})
.catch(() => {
toast.error(t("recipientSelector.addError"));
});
}
};
const handleRemoveRecipient = (email: string) => {
removeRecipients(shareId, { emails: [email] })
.then(() => {
setRecipients(recipients.filter((r) => r !== email));
toast.success(t("recipientSelector.removeSuccess"));
onSuccess();
})
.catch(() => {
toast.error(t("recipientSelector.removeError"));
});
};
const handleNotifyRecipients = async () => {
if (!shareAlias) return;
const link = `${window.location.origin}/s/${shareAlias}`;
const loadingToast = toast.loading(t("recipientSelector.sendingNotifications"));
try {
await notifyRecipients(shareId, { shareLink: link });
toast.dismiss(loadingToast);
toast.success(t("recipientSelector.notifySuccess"));
} catch (error) {
console.error(error);
toast.dismiss(loadingToast);
toast.error(t("recipientSelector.notifyError"));
}
};
return (
<div className="flex flex-col gap-4 mb-4">
<div className="flex gap-2">
<Input
placeholder={t("recipientSelector.emailPlaceholder")}
startContent={<FaEnvelope className="text-gray-400" />}
value={newRecipient}
onChange={(e) => setNewRecipient(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleAddRecipient()}
/>
<Button color="primary" startContent={<FaPlus />} onPress={handleAddRecipient}>
{t("recipientSelector.add")}
</Button>
</div>
<div className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{t("recipientSelector.recipients", { count: recipients.length })}</h3>
{recipients.length > 0 && shareAlias && smtpEnabled === "true" && (
<Button color="primary" size="sm" startContent={<FaBell />} variant="flat" onPress={handleNotifyRecipients}>
{t("recipientSelector.notifyAll")}
</Button>
)}
</div>
<div className="max-h-[400px] overflow-y-auto">
<div className="flex flex-col gap-2">
{recipients.length === 0 ? (
<div className="text-center py-8 text-gray-500">{t("recipientSelector.noRecipients")}</div>
) : (
recipients.map((email, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-foreground-100 rounded-lg hover:bg-foreground-200"
>
<div className="flex items-center gap-2">
<FaEnvelope className="text-gray-400" />
<span>{email}</span>
</div>
<Button
isIconOnly
color="danger"
size="sm"
variant="light"
onPress={() => handleRemoveRecipient(email)}
>
<FaTrash />
</Button>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { useTheme } from "@/hooks/use-theme";
import { Button } from "@heroui/button";
import { FC, useEffect, useState } from "react";
import { BsMoonFill, BsSunFill } from "react-icons/bs";
export const ThemeSwitch: FC = () => {
const [isMounted, setIsMounted] = useState(false);
const { theme, toggleTheme } = useTheme();
const isDark = theme === "dark";
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) return <div className="w-6 h-6" />;
return (
<Button
isIconOnly
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="text-default-500 mr-2"
size="sm"
variant="light"
onPress={toggleTheme}
>
{isDark ? <BsSunFill size={20} /> : <BsMoonFill size={18} />}
</Button>
);
};

View File

@@ -0,0 +1,56 @@
import { Navbar } from "@/components/layout/navbar";
import { DefaultFooter } from "@/components/ui/default-footer";
import { Breadcrumbs, BreadcrumbItem } from "@heroui/breadcrumbs";
import { Divider } from "@heroui/divider";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { TbLayoutDashboardFilled } from "react-icons/tb";
interface FileManagerLayoutProps {
children: ReactNode;
title: string;
icon: ReactNode;
breadcrumbLabel?: string;
showBreadcrumb?: boolean;
}
export function FileManagerLayout({
children,
title,
icon,
breadcrumbLabel,
showBreadcrumb = true,
}: FileManagerLayoutProps) {
const { t } = useTranslation();
return (
<div className="w-full min-h-screen flex flex-col">
<Navbar />
<div className="flex-1 max-w-7xl mx-auto w-full p-6 py-8">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
{icon}
<h1 className="text-2xl font-bold">{title}</h1>
</div>
<Divider />
{showBreadcrumb && breadcrumbLabel && (
<Breadcrumbs>
<BreadcrumbItem href="/dashboard">
<TbLayoutDashboardFilled className="text-sm mr-0.5" />
{t("navigation.dashboard")}
</BreadcrumbItem>
<BreadcrumbItem>
{icon} {breadcrumbLabel}
</BreadcrumbItem>
</Breadcrumbs>
)}
</div>
{children}
</div>
</div>
<DefaultFooter />
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { GridPattern } from "@/components/ui/grid-pattern";
import { BackgroundLights } from "@/pages/home/components/background-lights";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
export function LoadingScreen() {
const { t } = useTranslation();
return (
<div className="fixed inset-0 bg-background">
<GridPattern />
<BackgroundLights />
<div className="relative flex flex-col items-center justify-center h-full">
<motion.div
animate={{ scale: [1, 1.1, 1] }}
className="flex flex-col items-center gap-4"
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut",
}}
>
<span className="text-xl font-semibold text-primary">{t("common.loading")}</span>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { LanguageSwitcher } from "../general/language-switcher";
import { ThemeSwitch } from "@/components/general/theme-switch";
import { useAppInfo } from "@/contexts/app-info-context";
import { useAuth } from "@/contexts/auth-context";
import { logout as logoutAPI } from "@/http/endpoints";
import { Avatar } from "@heroui/avatar";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from "@heroui/dropdown";
import { Link } from "@heroui/link";
import { Navbar as HeroUINavbar, NavbarBrand, NavbarContent, NavbarItem } from "@heroui/navbar";
import { useTranslation } from "react-i18next";
import { FaCog, FaSignOutAlt, FaUser, FaUsersCog } from "react-icons/fa";
import { useNavigate } from "react-router-dom";
export function Navbar() {
const { t } = useTranslation();
const { user, isAdmin, logout } = useAuth();
const { appName, appLogo } = useAppInfo();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await logoutAPI();
logout();
navigate("/login");
} catch (err) {
console.error("Error logging out:", err);
}
};
return (
<HeroUINavbar
className="bg-background/70 backdrop-blur-sm border-b border-default-200/50"
maxWidth="xl"
position="sticky"
>
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
<NavbarBrand className="gap-3 max-w-fit">
<Link className="flex justify-start items-center gap-2" color="foreground" href="/dashboard">
{appLogo && <img alt={t("navbar.logoAlt")} className="h-8 w-8 object-contain" src={appLogo} />}
<p className="font-bold text-2xl">{appName}</p>
</Link>
</NavbarBrand>
</NavbarContent>
<NavbarContent className="flex basis-1/5 sm:basis-full" justify="end">
<NavbarItem className="flex gap-1">
<LanguageSwitcher />
<ThemeSwitch />
<Dropdown className="flex" placement="bottom-end">
<DropdownTrigger>
<Avatar
isBordered
className="cursor-pointer"
classNames={{
img: "opacity-100",
}}
radius="sm"
size="sm"
src={user?.image as string}
/>
</DropdownTrigger>
<DropdownMenu aria-label={t("navbar.profileMenu")} variant="flat">
<DropdownItem key="profile" className="h-14 gap-2">
<p className="font-semibold text-sm">
{user?.firstName} {user?.lastName}
</p>
<p className="font-semibold text-xs">{user?.email}</p>
</DropdownItem>
<DropdownItem key="userprofile">
<Link className="text-foreground text-sm flex items-center gap-2" href="/profile">
<FaUser />
{t("navbar.profile")}
</Link>
</DropdownItem>
{isAdmin ? (
<DropdownItem key="settings">
<Link className="text-foreground text-sm flex items-center gap-2" href="/settings">
<FaCog />
{t("navbar.settings")}
</Link>
</DropdownItem>
) : null}
{isAdmin ? (
<DropdownItem key="analytics">
<Link className="text-foreground text-sm flex items-center gap-2" href="/admin">
<FaUsersCog />
{t("navbar.usersManagement")}
</Link>
</DropdownItem>
) : null}
<DropdownItem key="logout" color="danger">
<button className="text-foreground text-sm flex items-center gap-2 w-full" onClick={handleLogout}>
<FaSignOutAlt />
{t("navbar.logout")}
</button>
</DropdownItem>
</DropdownMenu>
</Dropdown>
</NavbarItem>
</NavbarContent>
</HeroUINavbar>
);
}

View File

@@ -0,0 +1,36 @@
import { Breadcrumbs, BreadcrumbItem } from "@heroui/breadcrumbs";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { TbLayoutDashboardFilled } from "react-icons/tb";
interface ShareManagerLayoutProps {
children: ReactNode;
icon: ReactNode;
title: string;
breadcrumbLabel: string;
}
export function ShareManagerLayout({ children, icon, title, breadcrumbLabel }: ShareManagerLayoutProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Breadcrumbs>
<BreadcrumbItem href="/dashboard">
<TbLayoutDashboardFilled className="text-sm mr-0.5" />
{t("navigation.dashboard")}
</BreadcrumbItem>
<BreadcrumbItem>
{icon} {breadcrumbLabel}
</BreadcrumbItem>
</Breadcrumbs>
<div className="flex items-center gap-2">
{icon}
<h1 className="text-2xl font-bold">{title}</h1>
</div>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { createShare } from "@/http/endpoints";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/modal";
import { Switch } from "@heroui/switch";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
interface CreateShareModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function CreateShareModal({ isOpen, onClose, onSuccess }: CreateShareModalProps) {
const { t } = useTranslation();
const [formData, setFormData] = useState({
name: "",
password: "",
expiresAt: "",
isPasswordProtected: false,
maxViews: "",
});
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
try {
setIsLoading(true);
await createShare({
name: formData.name,
password: formData.isPasswordProtected ? formData.password : undefined,
expiration: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined,
maxViews: formData.maxViews ? parseInt(formData.maxViews) : undefined,
files: [],
});
toast.success(t("createShare.success"));
onSuccess();
onClose();
setFormData({
name: "",
password: "",
expiresAt: "",
isPasswordProtected: false,
maxViews: "",
});
} catch (error) {
toast.error(t("createShare.error"));
console.error(error);
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalContent>
<ModalHeader>{t("createShare.title")}</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<Input
label={t("createShare.nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<Input
label={t("createShare.expirationLabel")}
placeholder={t("createShare.expirationPlaceholder")}
type="datetime-local"
value={formData.expiresAt}
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
/>
<Input
label={t("createShare.maxViewsLabel")}
min="1"
placeholder={t("createShare.maxViewsPlaceholder")}
type="number"
value={formData.maxViews}
onChange={(e) => setFormData({ ...formData, maxViews: e.target.value })}
/>
<div className="flex items-center gap-2">
<Switch
isSelected={formData.isPasswordProtected}
onValueChange={(checked) =>
setFormData({
...formData,
isPasswordProtected: checked,
password: "",
})
}
>
{t("createShare.passwordProtection")}
</Switch>
</div>
{formData.isPasswordProtected && (
<Input
label={t("createShare.passwordLabel")}
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
{t("common.cancel")}
</Button>
<Button color="primary" isLoading={isLoading} onPress={handleSubmit}>
{t("createShare.create")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,115 @@
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
import { useTranslation } from "react-i18next";
interface FileActionsModalsProps {
fileToRename: { id: string; name: string; description?: string } | null;
fileToDelete: { id: string; name: string } | null;
onRename: (fileId: string, newName: string, description?: string) => Promise<void>;
onDelete: (fileId: string) => Promise<void>;
onCloseRename: () => void;
onCloseDelete: () => void;
}
export function FileActionsModals({
fileToRename,
fileToDelete,
onRename,
onDelete,
onCloseRename,
onCloseDelete,
}: FileActionsModalsProps) {
const { t } = useTranslation();
const splitFileName = (fullName: string) => {
const lastDotIndex = fullName.lastIndexOf(".");
return lastDotIndex === -1
? { name: fullName, extension: "" }
: {
name: fullName.substring(0, lastDotIndex),
extension: fullName.substring(lastDotIndex),
};
};
return (
<>
<Modal isOpen={!!fileToRename} size="sm" onClose={onCloseRename}>
<ModalContent>
<ModalHeader>{t("fileActions.editFile")}</ModalHeader>
<ModalBody>
{fileToRename && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Input
defaultValue={splitFileName(fileToRename.name).name}
label={t("fileActions.nameLabel")}
placeholder={t("fileActions.namePlaceholder")}
onKeyUp={(e) => {
if (e.key === "Enter" && fileToRename) {
const newName = e.currentTarget.value + splitFileName(fileToRename.name).extension;
onRename(fileToRename.id, newName);
}
}}
/>
<p className="text-sm text-gray-500">
{t("fileActions.extension")}: {splitFileName(fileToRename.name).extension}
</p>
</div>
<Input
defaultValue={fileToRename.description || ""}
label={t("fileActions.descriptionLabel")}
placeholder={t("fileActions.descriptionPlaceholder")}
/>
</div>
)}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onCloseRename}>
{t("common.cancel")}
</Button>
<Button
color="primary"
onPress={() => {
const nameInput = document.querySelector(
`input[placeholder="${t("fileActions.namePlaceholder")}"]`
) as HTMLInputElement;
const descInput = document.querySelector(
`input[placeholder="${t("fileActions.descriptionPlaceholder")}"]`
) as HTMLInputElement;
if (fileToRename && nameInput && descInput) {
const newName = nameInput.value + splitFileName(fileToRename.name).extension;
onRename(fileToRename.id, newName, descInput.value);
}
}}
>
{t("common.save")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal isOpen={!!fileToDelete} size="sm" onClose={onCloseDelete}>
<ModalContent>
<ModalHeader>{t("fileActions.deleteFile")}</ModalHeader>
<ModalBody>
<p>{t("fileActions.deleteConfirmation", { fileName: fileToDelete?.name })}</p>
<p className="text-sm text-gray-500 mt-2">{t("fileActions.deleteWarning")}</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onCloseDelete}>
{t("common.cancel")}
</Button>
<Button color="danger" onPress={() => fileToDelete && onDelete(fileToDelete.id)}>
{t("common.delete")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}

View File

@@ -0,0 +1,193 @@
/* eslint-disable jsx-a11y/media-has-caption */
import { getDownloadUrl } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons";
import { Button } from "@heroui/button";
import { Image } from "@heroui/image";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FaDownload } from "react-icons/fa";
import { toast } from "sonner";
interface FilePreviewModalProps {
isOpen: boolean;
onClose: () => void;
file: {
name: string;
objectName: string;
type?: string;
};
}
export function FilePreviewModal({ isOpen, onClose, file }: FilePreviewModalProps) {
const { t } = useTranslation();
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (isOpen && file.objectName) {
setIsLoading(true);
setPreviewUrl(null);
loadPreview();
}
}, [file.objectName, isOpen]);
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
const loadPreview = async () => {
if (!file.objectName) return;
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
setPreviewUrl(response.data.url);
} catch (error) {
console.error("Failed to load preview:", error);
toast.error(t("filePreview.loadError"));
} finally {
setIsLoading(false);
}
};
const handleDownload = async () => {
try {
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const downloadUrl = response.data.url;
const fileResponse = await fetch(downloadUrl);
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(error);
}
};
const getFileType = () => {
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension === "pdf") return "pdf";
if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension || "")) return "image";
if (["mp3", "wav", "ogg", "m4a"].includes(extension || "")) return "audio";
if (["mp4", "webm", "ogg", "mov", "avi", "mkv"].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-500" />
<p className="text-gray-500">{t("filePreview.loading")}</p>
</div>
);
}
if (!previewUrl) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`text-6xl ${color}`} />
<p className="text-gray-500">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-gray-400">{t("filePreview.downloadToView")}</p>
</div>
);
}
switch (fileType) {
case "pdf":
return (
<div className="w-full h-[70vh] max-h-[600px] overflow-hidden">
<iframe key={file.objectName} className="w-full h-full" src={previewUrl} title={file.name} />
</div>
);
case "image":
return (
<div className="flex items-center justify-center max-h-[70vh]">
<Image
key={file.objectName}
alt={file.name}
classNames={{
wrapper: "max-w-full max-h-[600px]",
img: "object-contain",
}}
height={600}
loading="lazy"
radius="lg"
src={previewUrl}
/>
</div>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center gap-6 py-12">
<FileIcon className={`text-6xl ${color}`} />
<audio controls className="w-full max-w-md">
<source src={previewUrl} type={`audio/${file.name.split(".").pop()}`} />
{t("filePreview.audioNotSupported")}
</audio>
</div>
);
case "video":
return (
<div className="flex flex-col items-center justify-center gap-6 py-12">
<video controls className="w-full max-w-4xl">
<source src={previewUrl} type={`video/${file.name.split(".").pop()}`} />
{t("filePreview.videoNotSupported")}
</video>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<FileIcon className={`text-6xl ${color}`} />
<p className="text-gray-500">{t("filePreview.notAvailable")}</p>
<p className="text-sm text-gray-400">{t("filePreview.downloadToView")}</p>
</div>
);
}
};
return (
<Modal isOpen={isOpen} size="3xl" onClose={onClose}>
<ModalContent>
<ModalHeader className="flex justify-between items-center">
<div className="flex items-center gap-2">
{getFileIcon(file.name).icon({ className: "text-xl" })}
<span>{file.name}</span>
</div>
</ModalHeader>
<ModalBody>{renderPreview()}</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
{t("common.close")}
</Button>
<Button color="primary" startContent={<FaDownload />} onPress={handleDownload}>
{t("common.download")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,103 @@
import type { ListUserShares200SharesItem } from "@/http/models/listUserShares200SharesItem";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/modal";
import { nanoid } from "nanoid";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
interface GenerateShareLinkModalProps {
shareId: string | null;
share: ListUserShares200SharesItem | null;
onClose: () => void;
onSuccess: () => void;
onGenerate: (shareId: string, alias: string) => Promise<void>;
}
export function GenerateShareLinkModal({
shareId,
share,
onClose,
onSuccess,
onGenerate,
}: GenerateShareLinkModalProps) {
const { t } = useTranslation();
const [alias, setAlias] = useState(() => nanoid(10));
const [isLoading, setIsLoading] = useState(false);
const [generatedLink, setGeneratedLink] = useState("");
const [isEdit, setIsEdit] = useState(false);
useEffect(() => {
if (shareId && share?.alias?.alias) {
setIsEdit(true);
setAlias(share.alias.alias);
} else {
setIsEdit(false);
setAlias(nanoid(10));
}
setGeneratedLink("");
}, [shareId, share]);
const handleGenerate = async () => {
if (!shareId) return;
try {
setIsLoading(true);
await onGenerate(shareId, alias);
const link = `${window.location.origin}/s/${alias}`;
setGeneratedLink(link);
onSuccess();
toast.success(t("generateShareLink.success"));
} catch (error) {
console.error(error);
toast.error(t("generateShareLink.error"));
} finally {
setIsLoading(false);
}
};
const handleCopyLink = () => {
navigator.clipboard.writeText(generatedLink);
toast.success(t("generateShareLink.copied"));
};
return (
<Modal isOpen={!!shareId} onClose={onClose}>
<ModalContent>
<ModalHeader>{isEdit ? t("generateShareLink.updateTitle") : t("generateShareLink.generateTitle")}</ModalHeader>
<ModalBody>
{!generatedLink ? (
<div className="space-y-4">
<p className="text-sm text-gray-500">
{isEdit ? t("generateShareLink.updateDescription") : t("generateShareLink.generateDescription")}
</p>
<Input
placeholder={t("generateShareLink.aliasPlaceholder")}
value={alias}
onChange={(e) => setAlias(e.target.value)}
/>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-gray-500">{t("generateShareLink.linkReady")}</p>
<Input readOnly value={generatedLink} />
</div>
)}
</ModalBody>
<ModalFooter>
{!generatedLink ? (
<Button color="primary" disabled={!alias || isLoading} onPress={handleGenerate}>
{isEdit ? t("generateShareLink.updateButton") : t("generateShareLink.generateButton")}
</Button>
) : (
<Button color="primary" onPress={handleCopyLink}>
{t("generateShareLink.copyButton")}
</Button>
)}
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,218 @@
import { FileSelector } from "../general/file-selector";
import { RecipientSelector } from "../general/recipient-selector";
import { updateSharePassword } from "@/http/endpoints";
import { Button } from "@heroui/button";
import { Input } from "@heroui/input";
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/modal";
import { Switch } from "@heroui/switch";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export interface ShareActionsModalsProps {
shareToDelete: any;
shareToEdit: any;
shareToManageFiles: any;
shareToManageRecipients: any;
onCloseDelete: () => void;
onCloseEdit: () => void;
onCloseManageFiles: () => void;
onCloseManageRecipients: () => void;
onDelete: (shareId: string) => Promise<void>;
onEdit: (shareId: string, data: any) => Promise<void>;
onManageFiles: (shareId: string, files: string[]) => Promise<void>;
onManageRecipients: (shareId: string, recipients: string[]) => Promise<void>;
onSuccess: () => void;
}
export function ShareActionsModals({
shareToDelete,
shareToEdit,
shareToManageFiles,
shareToManageRecipients,
onCloseDelete,
onCloseEdit,
onCloseManageFiles,
onCloseManageRecipients,
onDelete,
onEdit,
onManageFiles,
onSuccess,
}: ShareActionsModalsProps) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [editForm, setEditForm] = useState({
name: "",
expiresAt: "",
isPasswordProtected: false,
password: "",
maxViews: "",
});
useEffect(() => {
if (shareToEdit) {
setEditForm({
name: shareToEdit.name || "",
expiresAt: shareToEdit.expiration ? new Date(shareToEdit.expiration).toISOString().slice(0, 16) : "",
isPasswordProtected: Boolean(shareToEdit.security?.hasPassword),
password: "",
maxViews: shareToEdit.security?.maxViews?.toString() || "",
});
}
}, [shareToEdit]);
const handleDelete = async () => {
if (!shareToDelete) return;
setIsLoading(true);
await onDelete(shareToDelete.id);
setIsLoading(false);
};
const handleEdit = async () => {
if (!shareToEdit) return;
setIsLoading(true);
try {
const updateData = {
name: editForm.name,
expiration: editForm.expiresAt ? new Date(editForm.expiresAt).toISOString() : undefined,
maxViews: editForm.maxViews ? parseInt(editForm.maxViews) : null,
};
await onEdit(shareToEdit.id, updateData);
if (!editForm.isPasswordProtected && shareToEdit.security.hasPassword) {
await updateSharePassword(shareToEdit.id, { password: "" });
} else if (editForm.isPasswordProtected && editForm.password) {
await updateSharePassword(shareToEdit.id, { password: editForm.password });
}
onSuccess();
onCloseEdit();
toast.success(t("shareActions.editSuccess"));
} catch (error) {
console.error(error);
toast.error(t("shareActions.editError"));
} finally {
setIsLoading(false);
}
};
return (
<>
<Modal isOpen={!!shareToDelete} onClose={onCloseDelete}>
<ModalContent>
<ModalHeader>{t("shareActions.deleteTitle")}</ModalHeader>
<ModalBody>{t("shareActions.deleteConfirmation")}</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onCloseDelete}>
{t("common.cancel")}
</Button>
<Button color="danger" isLoading={isLoading} onPress={handleDelete}>
{t("common.delete")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal isOpen={!!shareToEdit} onClose={onCloseEdit}>
<ModalContent>
<ModalHeader>{t("shareActions.editTitle")}</ModalHeader>
<ModalBody>
<div className="flex flex-col gap-4">
<Input
label={t("shareActions.nameLabel")}
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
<Input
label={t("shareActions.expirationLabel")}
placeholder={t("shareActions.expirationPlaceholder")}
type="datetime-local"
value={editForm.expiresAt}
onChange={(e) => setEditForm({ ...editForm, expiresAt: e.target.value })}
/>
<Input
label={t("shareActions.maxViewsLabel")}
min="1"
placeholder={t("shareActions.maxViewsPlaceholder")}
type="number"
value={editForm.maxViews}
onChange={(e) => setEditForm({ ...editForm, maxViews: e.target.value })}
/>
<div className="flex items-center gap-2">
<Switch
isSelected={editForm.isPasswordProtected}
onValueChange={(checked) =>
setEditForm({
...editForm,
isPasswordProtected: checked,
password: "",
})
}
>
{t("shareActions.passwordProtection")}
</Switch>
</div>
{editForm.isPasswordProtected && (
<Input
label={
shareToEdit?.security?.hasPassword
? t("shareActions.newPasswordLabel")
: t("shareActions.passwordLabel")
}
placeholder={
shareToEdit?.security?.hasPassword
? t("shareActions.newPasswordPlaceholder")
: t("shareActions.passwordPlaceholder")
}
type="password"
value={editForm.password}
onChange={(e) => setEditForm({ ...editForm, password: e.target.value })}
/>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onCloseEdit}>
{t("common.cancel")}
</Button>
<Button color="primary" isLoading={isLoading} onPress={handleEdit}>
{t("common.save")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal isOpen={!!shareToManageFiles} size="2xl" onClose={onCloseManageFiles}>
<ModalContent>
<ModalHeader>{t("shareActions.manageFilesTitle")}</ModalHeader>
<ModalBody>
<FileSelector
selectedFiles={shareToManageFiles?.files?.map((file: { id: string }) => file.id) || []}
shareId={shareToManageFiles?.id}
onSave={async (files) => {
await onManageFiles(shareToManageFiles?.id, files);
onSuccess();
}}
/>
</ModalBody>
</ModalContent>
</Modal>
<Modal isOpen={!!shareToManageRecipients} size="2xl" onClose={onCloseManageRecipients}>
<ModalContent>
<ModalHeader>{t("shareActions.manageRecipientsTitle")}</ModalHeader>
<ModalBody>
<RecipientSelector
selectedRecipients={shareToManageRecipients?.recipients || []}
shareAlias={shareToManageRecipients?.alias?.alias}
shareId={shareToManageRecipients?.id}
onSuccess={onSuccess}
/>
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

View File

@@ -0,0 +1,195 @@
import { getShare } from "@/http/endpoints";
import { getFileIcon } from "@/utils/file-icons";
import { Button } from "@heroui/button";
import { Chip } from "@heroui/chip";
import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/modal";
import { Spinner } from "@heroui/spinner";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaLock, FaUnlock, FaEnvelope } from "react-icons/fa";
import { toast } from "sonner";
interface ShareDetailsModalProps {
shareId: string | null;
onClose: () => void;
}
interface ShareFile {
id: string;
name: string;
description: string | null;
extension: string;
size: number;
objectName: string;
userId: string;
createdAt: string;
updatedAt: string;
}
interface ShareRecipient {
id: string;
email: string;
createdAt: string;
updatedAt: string;
}
export function ShareDetailsModal({ shareId, onClose }: ShareDetailsModalProps) {
const { t } = useTranslation();
const [share, setShare] = useState<any>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (shareId) {
loadShareDetails();
}
}, [shareId]);
const loadShareDetails = async () => {
if (!shareId) return;
setIsLoading(true);
try {
const response = await getShare(shareId);
setShare(response.data.share);
} catch (error) {
console.error(error);
toast.error(t("shareDetails.loadError"));
} finally {
setIsLoading(false);
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return t("shareDetails.notAvailable");
try {
return format(new Date(dateString), "MM/dd/yyyy HH:mm");
} catch (error) {
console.error(error);
console.error("Invalid date:", dateString);
return t("shareDetails.invalidDate");
}
};
if (!share) return null;
return (
<Modal isOpen={!!shareId} size="2xl" onClose={onClose}>
<ModalContent className="p-1">
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-semibold">{t("shareDetails.title")}</h2>
<p className="text-sm text-default-500">{t("shareDetails.subtitle")}</p>
</ModalHeader>
<ModalBody>
{isLoading ? (
<div className="flex justify-center py-8">
<Spinner />
</div>
) : (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-4">
<div className="rounded-lg border border-default-200 bg-default-50 p-4">
<h3 className="font-medium">{t("shareDetails.basicInfo")}</h3>
<div className="mt-3 space-y-3">
<div>
<span className="text-sm text-default-500">{t("shareDetails.name")}</span>
<p className="mt-1 font-medium">{share.name || t("shareDetails.untitled")}</p>
</div>
<div>
<span className="text-sm text-default-500">{t("shareDetails.views")}</span>
<p className="mt-1 font-medium">{share.views}</p>
</div>
</div>
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
<div className="rounded-lg border border-default-200 bg-default-50 p-4">
<h3 className="font-medium">{t("shareDetails.dates")}</h3>
<div className="mt-3 space-y-3">
<div>
<span className="text-sm text-default-500">{t("shareDetails.created")}</span>
<p className="mt-1 font-medium">{formatDate(share.createdAt)}</p>
</div>
<div>
<span className="text-sm text-default-500">{t("shareDetails.expires")}</span>
<p className="mt-1 font-medium">
{share.expiration ? formatDate(share.expiration) : t("shareDetails.never")}
</p>
</div>
</div>
</div>
</div>
</div>
{/* Full Width Sections */}
<div className="rounded-lg border border-default-200 bg-default-50 p-4">
<h3 className="font-medium">{t("shareDetails.security")}</h3>
<div className="mt-3 flex gap-2">
{share.security?.hasPassword ? (
<Chip color="warning" startContent={<FaLock className="ml-1.5 mr-1 text-sm" />} variant="flat">
{t("shareDetails.passwordProtected")}
</Chip>
) : (
<Chip color="success" startContent={<FaUnlock className="ml-1.5 mr-1 text-sm" />} variant="flat">
{t("shareDetails.publicAccess")}
</Chip>
)}
{share.security?.maxViews && (
<Chip color="primary" variant="flat">
{t("shareDetails.maxViews", { count: share.security.maxViews })}
</Chip>
)}
</div>
</div>
<div className="rounded-lg border border-default-200 bg-default-50 p-4">
<h3 className="font-medium">{t("shareDetails.files", { count: share.files?.length || 0 })}</h3>
<div className="mt-3 flex flex-wrap gap-2">
{share.files?.map((file: ShareFile) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
return (
<Chip
key={file.id}
startContent={<FileIcon className={`ml-1.5 text-sm ${color}`} />}
variant="flat"
>
{file.name}
</Chip>
);
})}
</div>
</div>
<div className="rounded-lg border border-default-200 bg-default-50 p-4">
<h3 className="font-medium">
{t("shareDetails.recipients", { count: share.recipients?.length || 0 })}
</h3>
<div className="mt-3 flex flex-wrap gap-2">
{share.recipients?.map((recipient: ShareRecipient) => (
<Chip
key={recipient.id}
startContent={<FaEnvelope className={"ml-2 mr-1 text-sm"} />}
variant="flat"
>
{recipient.email}
</Chip>
))}
</div>
</div>
</div>
)}
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
{t("common.close")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,175 @@
import { getPresignedUrl, registerFile } from "@/http/endpoints";
import { generateSafeFileName } from "@/utils/file-utils";
import { Button } from "@heroui/button";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/modal";
import { Progress } from "@heroui/progress";
import axios from "axios";
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { FaCloudUploadAlt, FaFile, FaFileAlt, FaFileImage, FaFilePdf, FaFileWord } from "react-icons/fa";
import { toast } from "sonner";
interface UploadFileModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export function UploadFileModal({ isOpen, onClose, onSuccess }: UploadFileModalProps) {
const { t } = useTranslation();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
if (file.type.startsWith("image/")) {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
} else {
setPreviewUrl(null);
}
}
};
const getFileIcon = (fileType: string) => {
if (fileType.startsWith("image/")) return <FaFileImage className="text-4xl text-blue-500" />;
if (fileType.includes("pdf")) return <FaFilePdf className="text-4xl text-red-500" />;
if (fileType.includes("word")) return <FaFileWord className="text-4xl text-blue-700" />;
return <FaFileAlt className="text-4xl text-gray-500" />;
};
const handleUpload = async () => {
if (!selectedFile) return;
try {
setIsUploading(true);
setUploadProgress(0);
const fileName = selectedFile.name;
const extension = fileName.split(".").pop() || "";
const safeObjectName = generateSafeFileName(fileName);
const presignedResponse = await getPresignedUrl({
filename: safeObjectName.replace(`.${extension}`, ""),
extension: extension,
});
const { url, objectName } = presignedResponse.data;
await axios.put(url, selectedFile, {
headers: {
"Content-Type": selectedFile.type,
},
onUploadProgress: (progressEvent) => {
const progress = (progressEvent.loaded / (progressEvent.total || selectedFile.size)) * 100;
setUploadProgress(Math.round(progress));
},
});
await registerFile({
name: fileName,
objectName: objectName,
size: selectedFile.size,
extension: extension,
});
toast.success(t("uploadFile.success"));
onSuccess?.();
handleClose();
} catch (error) {
console.error("Upload failed:", error);
toast.error(t("uploadFile.error"));
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
setUploadProgress(0);
setIsUploading(false);
onClose();
};
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">{t("uploadFile.title")}</ModalHeader>
<ModalBody>
<div className="flex flex-col items-center gap-4">
<input ref={fileInputRef} className="hidden" type="file" onChange={handleFileSelect} />
{!selectedFile ? (
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-8 cursor-pointer hover:border-primary-500 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<div className="flex flex-col items-center gap-2">
<FaCloudUploadAlt className="text-4xl text-gray-400" />
<p className="text-gray-600">{t("uploadFile.selectFile")}</p>
</div>
</div>
) : (
<div className="w-full">
<div className="flex flex-col items-center gap-4 mb-4">
{previewUrl ? (
<img
alt={t("uploadFile.preview")}
className="max-w-full h-auto max-h-48 rounded-lg object-contain"
src={previewUrl}
/>
) : (
getFileIcon(selectedFile.type)
)}
<div className="flex items-center gap-2">
<FaFile className="text-xl text-gray-500" />
<span className="font-medium">{selectedFile.name}</span>
</div>
</div>
{isUploading && (
<Progress
showValueLabel
aria-label={t("uploadFile.uploadProgress")}
className="w-full"
value={uploadProgress}
/>
)}
</div>
)}
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={handleClose}>
{t("common.cancel")}
</Button>
<Button
color="primary"
isDisabled={!selectedFile || isUploading}
isLoading={isUploading}
onPress={handleUpload}
>
{t("uploadFile.upload")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,21 @@
import { LoadingScreen } from "../layout/loading-screen";
import { useAuth } from "@/contexts/auth-context";
import { Navigate } from "react-router-dom";
export function AdminProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isAdmin } = useAuth();
if (isAuthenticated === null || isAdmin === null) {
return <LoadingScreen />;
}
if (!isAuthenticated) {
return <Navigate replace to="/login" />;
}
if (!isAdmin) {
return <Navigate replace to="/dashboard" />;
}
return children;
}

View File

@@ -0,0 +1,17 @@
import { LoadingScreen } from "../layout/loading-screen";
import { useAuth } from "@/contexts/auth-context";
import { Navigate } from "react-router-dom";
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
if (isAuthenticated === null) {
return <LoadingScreen />;
}
if (!isAuthenticated) {
return <Navigate replace to="/login" />;
}
return children;
}

View File

@@ -0,0 +1,110 @@
import { getFileIcon } from "@/utils/file-icons";
import { formatFileSize } from "@/utils/format-file-size";
import { Button } from "@heroui/button";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from "@heroui/dropdown";
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell } from "@heroui/table";
import { useTranslation } from "react-i18next";
import { FaEye, FaEdit, FaDownload, FaTrash, FaEllipsisV } from "react-icons/fa";
interface File {
id: string;
name: string;
description?: string;
size: number;
objectName: string;
createdAt: string;
updatedAt: string;
}
interface FilesTableProps {
files: File[];
onPreview: (file: File) => void;
onRename: (file: File) => void;
onDownload: (objectName: string, fileName: string) => void;
onDelete: (file: File) => void;
}
export function FilesTable({ files, onPreview, onRename, onDownload, onDelete }: FilesTableProps) {
const { t } = useTranslation();
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
};
return (
<Table aria-label={t("filesTable.ariaLabel")}>
<TableHeader>
<TableColumn>{t("filesTable.columns.name")}</TableColumn>
<TableColumn>{t("filesTable.columns.description")}</TableColumn>
<TableColumn>{t("filesTable.columns.size")}</TableColumn>
<TableColumn>{t("filesTable.columns.createdAt")}</TableColumn>
<TableColumn>{t("filesTable.columns.updatedAt")}</TableColumn>
<TableColumn className="w-[70px]">{t("filesTable.columns.actions")}</TableColumn>
</TableHeader>
<TableBody>
{files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
return (
<TableRow key={file.id}>
<TableCell>
<div className="flex items-center gap-2">
<FileIcon className={`text-lg ${color}`} />
<span className="truncate max-w-[250px]" title={file.name}>
{file.name}
</span>
</div>
</TableCell>
<TableCell>{file.description || "-"}</TableCell>
<TableCell>{formatFileSize(file.size)}</TableCell>
<TableCell>{formatDateTime(file.createdAt)}</TableCell>
<TableCell>{formatDateTime(file.updatedAt || file.createdAt)}</TableCell>
<TableCell className="text-right">
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button isIconOnly aria-label={t("filesTable.actions.menu")} variant="light">
<FaEllipsisV />
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem key="preview" startContent={<FaEye />} onPress={() => onPreview(file)}>
{t("filesTable.actions.preview")}
</DropdownItem>
<DropdownItem key="edit" startContent={<FaEdit />} onPress={() => onRename(file)}>
{t("filesTable.actions.edit")}
</DropdownItem>
<DropdownItem
key="download"
startContent={<FaDownload />}
onPress={() => onDownload(file.objectName, file.name)}
>
{t("filesTable.actions.download")}
</DropdownItem>
<DropdownItem
key="delete"
className="text-danger"
color="danger"
startContent={<FaTrash />}
onPress={() => onDelete(file)}
>
{t("filesTable.actions.delete")}
</DropdownItem>
</DropdownMenu>
</Dropdown>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,149 @@
import { useShareContext } from "../../contexts/ShareContext";
import { Button } from "@heroui/button";
import { Chip } from "@heroui/chip";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "@heroui/dropdown";
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@heroui/table";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import {
FaEdit,
FaTrash,
FaUsers,
FaFolder,
FaEllipsisV,
FaLock,
FaUnlock,
FaEye,
FaCopy,
FaLink,
FaEnvelope,
} from "react-icons/fa";
export interface SharesTableProps {
shares: any[];
onDelete: (share: any) => void;
onEdit: (share: any) => void;
onManageFiles: (share: any) => void;
onManageRecipients: (share: any) => void;
onViewDetails: (share: any) => void;
onGenerateLink: (share: any) => void;
onCopyLink: (share: any) => void;
onNotifyRecipients: (share: any) => void;
}
export function SharesTable({
shares,
onDelete,
onEdit,
onManageFiles,
onManageRecipients,
onViewDetails,
onGenerateLink,
onCopyLink,
onNotifyRecipients,
}: SharesTableProps) {
const { t } = useTranslation();
const { smtpEnabled } = useShareContext();
return (
<Table aria-label={t("sharesTable.ariaLabel")}>
<TableHeader>
<TableColumn>{t("sharesTable.columns.name")}</TableColumn>
<TableColumn>{t("sharesTable.columns.createdAt")}</TableColumn>
<TableColumn>{t("sharesTable.columns.expiresAt")}</TableColumn>
<TableColumn>{t("sharesTable.columns.status")}</TableColumn>
<TableColumn>{t("sharesTable.columns.security")}</TableColumn>
<TableColumn>{t("sharesTable.columns.files")}</TableColumn>
<TableColumn>{t("sharesTable.columns.recipients")}</TableColumn>
<TableColumn className="w-[70px]">{t("sharesTable.columns.actions")}</TableColumn>
</TableHeader>
<TableBody>
{shares.map((share) => (
<TableRow key={share.id}>
<TableCell>{share.name}</TableCell>
<TableCell>{format(new Date(share.createdAt), "MM/dd/yyyy HH:mm")}</TableCell>
<TableCell>
{share.expiration ? format(new Date(share.expiration), "MM/dd/yyyy HH:mm") : t("sharesTable.never")}
</TableCell>
<TableCell>
<Chip
color={!share.expiration ? "success" : new Date(share.expiration) > new Date() ? "success" : "danger"}
variant="flat"
>
{!share.expiration
? t("sharesTable.status.neverExpires")
: new Date(share.expiration) > new Date()
? t("sharesTable.status.active")
: t("sharesTable.status.expired")}
</Chip>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{share.security.hasPassword ? (
<Chip color="warning" startContent={<FaLock className="ml-1.5 text-sm" />} variant="flat">
{t("sharesTable.security.protected")}
</Chip>
) : (
<Chip color="success" startContent={<FaUnlock className="ml-1.5 text-sm" />} variant="flat">
{t("sharesTable.security.public")}
</Chip>
)}
</div>
</TableCell>
<TableCell>{t("sharesTable.filesCount", { count: share.files?.length || 0 })}</TableCell>
<TableCell>{t("sharesTable.recipientsCount", { count: share.recipients?.length || 0 })}</TableCell>
<TableCell className="text-right">
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Button isIconOnly aria-label={t("sharesTable.actions.menu")} variant="light">
<FaEllipsisV />
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem key="edit" startContent={<FaEdit />} onPress={() => onEdit(share)}>
{t("sharesTable.actions.edit")}
</DropdownItem>
<DropdownItem key="files" startContent={<FaFolder />} onPress={() => onManageFiles(share)}>
{t("sharesTable.actions.manageFiles")}
</DropdownItem>
<DropdownItem key="recipients" startContent={<FaUsers />} onPress={() => onManageRecipients(share)}>
{t("sharesTable.actions.manageRecipients")}
</DropdownItem>
<DropdownItem
key="view"
startContent={<FaEye className="text-sm" />}
onPress={() => onViewDetails(share)}
>
{t("sharesTable.actions.viewDetails")}
</DropdownItem>
<DropdownItem key="generateLink" startContent={<FaLink />} onPress={() => onGenerateLink(share)}>
{share.alias ? t("sharesTable.actions.editLink") : t("sharesTable.actions.generateLink")}
</DropdownItem>
{share.alias && (
<DropdownItem key="copyLink" startContent={<FaCopy />} onPress={() => onCopyLink(share)}>
{t("sharesTable.actions.copyLink")}
</DropdownItem>
)}
{share.recipients?.length > 0 && share.alias && smtpEnabled === "true" && (
<DropdownItem key="notify" startContent={<FaEnvelope />} onPress={() => onNotifyRecipients(share)}>
{t("sharesTable.actions.notifyRecipients")}
</DropdownItem>
)}
<DropdownItem
key="delete"
className="text-danger"
color="danger"
startContent={<FaTrash />}
onPress={() => onDelete(share)}
>
{t("sharesTable.actions.delete")}
</DropdownItem>
</DropdownMenu>
</Dropdown>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,20 @@
import { Link } from "@heroui/link";
import { useTranslation } from "react-i18next";
export function DefaultFooter() {
const { t } = useTranslation();
return (
<footer className="w-full flex items-center justify-center py-3 h-16">
<Link
isExternal
className="flex items-center gap-1 text-current"
href="https://kyantech.com.br"
title={t("footer.kyanHomepage")}
>
<span className="text-default-600 text-xs sm:text-sm">{t("footer.poweredBy")}</span>
<p className="text-green-700 text-xs sm:text-sm">Kyantech Solutions</p>
</Link>
</footer>
);
}

View File

@@ -0,0 +1,15 @@
import { motion } from "framer-motion";
export function GridPattern() {
return (
<div className="absolute inset-0 -z-10 overflow-hidden">
<motion.div
animate={{ opacity: 0.4 }}
className="absolute inset-0 bg-grid-pattern bg-[length:50px_50px] dark:opacity-10 opacity-20"
initial={{ opacity: 0 }}
transition={{ duration: 1 }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/80 to-transparent" />
</div>
);
}

View File

@@ -0,0 +1,11 @@
import axios from "axios";
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
"Content-Type": "application/json",
},
withCredentials: true,
});
export default axiosInstance;

View File

@@ -0,0 +1,79 @@
import arTranslations from "../locales/ar-SA.json";
import deTranslations from "../locales/de-DE.json";
import enTranslations from "../locales/en-US.json";
import esTranslations from "../locales/es-ES.json";
import frTranslations from "../locales/fr-FR.json";
import hiTranslations from "../locales/hi-IN.json";
import jaTranslations from "../locales/ja-JP.json";
import koTranslations from "../locales/ko-KR.json";
import ptTranslations from "../locales/pt-BR.json";
import ruTranslations from "../locales/ru-RU.json";
import trTranslations from "../locales/tr-TR.json";
import zhTranslations from "../locales/zh-CN.json";
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
"en-US": {
translation: enTranslations,
},
"pt-BR": {
translation: ptTranslations,
},
"fr-FR": {
translation: frTranslations,
},
"es-ES": {
translation: esTranslations,
},
"de-DE": {
translation: deTranslations,
},
"ru-RU": {
translation: ruTranslations,
},
"hi-IN": {
translation: hiTranslations,
},
"ar-SA": {
translation: arTranslations,
},
"ja-JP": {
translation: jaTranslations,
},
"ko-KR": {
translation: koTranslations,
},
"tr-TR": {
translation: trTranslations,
},
"zh-CN": {
translation: zhTranslations,
},
},
fallbackLng: "en-US",
supportedLngs: [
"en-US",
"pt-BR",
"fr-FR",
"es-ES",
"de-DE",
"ru-RU",
"hi-IN",
"ar-SA",
"ja-JP",
"ko-KR",
"tr-TR",
"zh-CN",
],
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -0,0 +1,29 @@
export type SiteConfig = typeof siteConfig;
export const siteConfig = {
navItems: [
{
label: "Login",
href: "/login",
},
{
label: "Docs",
href: "/docs",
},
],
navMenuItems: [
{
label: "Login",
href: "/login",
},
{
label: "Docs",
href: "/docs",
},
],
links: {
github: "https://github.com/kyantech/Palmr",
docs: "https://palmr.kyantech.com",
sponsor: "https://patreon.com/",
},
};

View File

@@ -0,0 +1,39 @@
import { getAllConfigs } from "@/http/endpoints";
import { createContext, useContext, useState, useEffect } from "react";
interface ShareContextType {
smtpEnabled: string;
refreshShareContext: () => Promise<void>;
}
const ShareContext = createContext<ShareContextType>({
smtpEnabled: "false",
refreshShareContext: async () => {},
});
export function ShareProvider({ children }: { children: React.ReactNode }) {
const [smtpEnabled, setSmtpEnabled] = useState("false");
const loadConfigs = async () => {
try {
const response = await getAllConfigs();
const smtpConfig = response.data.configs.find((config: any) => config.key === "smtpEnabled");
setSmtpEnabled(smtpConfig?.value || "false");
} catch (error) {
console.error("Failed to load SMTP config:", error);
}
};
const refreshShareContext = async () => {
await loadConfigs();
};
useEffect(() => {
loadConfigs();
}, []);
return <ShareContext.Provider value={{ smtpEnabled, refreshShareContext }}>{children}</ShareContext.Provider>;
}
export const useShareContext = () => useContext(ShareContext);

View File

@@ -0,0 +1,70 @@
import { getAppInfo } from "@/http/endpoints";
import { createContext, useContext, useEffect, useState } from "react";
interface AppInfoContextType {
appName: string;
appLogo: string;
setAppName: (name: string) => void;
setAppLogo: (logo: string) => void;
refreshAppInfo: () => Promise<void>;
}
const AppInfoContext = createContext<AppInfoContextType>({
appName: "",
appLogo: "",
setAppName: () => {},
setAppLogo: () => {},
refreshAppInfo: async () => {},
});
export function AppInfoProvider({ children }: { children: React.ReactNode }) {
const [appName, setAppName] = useState("");
const [appLogo, setAppLogo] = useState("");
const updateFavicon = (logo: string) => {
const link = document.querySelector<HTMLLinkElement>("link[rel*='icon']") || document.createElement("link");
link.type = "image/x-icon";
link.rel = "shortcut icon";
link.href = logo || "/favicon.ico";
document.head.appendChild(link);
};
const updateTitle = (name: string) => {
document.title = name;
};
const refreshAppInfo = async () => {
try {
const response = await getAppInfo();
setAppName(response.data.appName);
setAppLogo(response.data.appLogo);
updateTitle(response.data.appName);
updateFavicon(response.data.appLogo);
} catch (error) {
console.error("Failed to fetch app info:", error);
}
};
useEffect(() => {
updateTitle(appName);
}, [appName]);
useEffect(() => {
updateFavicon(appLogo);
}, [appLogo]);
useEffect(() => {
refreshAppInfo();
}, []);
return (
<AppInfoContext.Provider value={{ appName, appLogo, setAppName, setAppLogo, refreshAppInfo }}>
{children}
</AppInfoContext.Provider>
);
}
export const useAppInfo = () => useContext(AppInfoContext);

Some files were not shown because too many files have changed in this diff Show More