mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
feat: input developed code
This commit is contained in:
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"eslint.workingDirectories": [
|
||||
{
|
||||
"mode": "auto"
|
||||
}
|
||||
]
|
||||
}
|
25
LICENSE
Normal file
25
LICENSE
Normal 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.
|
9
apps/server/.dockerignore
Normal file
9
apps/server/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
.cache
|
12
apps/server/.env.example
Normal file
12
apps/server/.env.example
Normal 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
3
apps/server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.env
|
||||
dist/*
|
7
apps/server/.prettierrc.json
Normal file
7
apps/server/.prettierrc.json
Normal 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
26
apps/server/Dockerfile
Normal 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"]
|
62
apps/server/docker-compose-dev.yaml
Normal file
62
apps/server/docker-compose-dev.yaml
Normal 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
|
24
apps/server/eslint.config.mjs
Normal file
24
apps/server/eslint.config.mjs
Normal 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
53
apps/server/package.json
Normal 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
4243
apps/server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
0
apps/server/prisma/dev.db
Normal file
0
apps/server/prisma/dev.db
Normal file
178
apps/server/prisma/migrations/20250220194959_init/migration.sql
Normal file
178
apps/server/prisma/migrations/20250220194959_init/migration.sql
Normal 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;
|
3
apps/server/prisma/migrations/migration_lock.toml
Normal file
3
apps/server/prisma/migrations/migration_lock.toml
Normal 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"
|
145
apps/server/prisma/schema.prisma
Normal file
145
apps/server/prisma/schema.prisma
Normal 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
178
apps/server/prisma/seed.ts
Normal 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();
|
||||
});
|
15
apps/server/scripts/start.sh
Normal file
15
apps/server/scripts/start.sh
Normal 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
69
apps/server/src/app.ts
Normal 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;
|
||||
}
|
13
apps/server/src/config/minio.config.ts
Normal file
13
apps/server/src/config/minio.config.ts
Normal 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;
|
27
apps/server/src/config/swagger.config.ts
Normal file
27
apps/server/src/config/swagger.config.ts
Normal 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
17
apps/server/src/env.ts
Normal 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);
|
87
apps/server/src/modules/app/controller.ts
Normal file
87
apps/server/src/modules/app/controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
21
apps/server/src/modules/app/dto.ts
Normal file
21
apps/server/src/modules/app/dto.ts
Normal 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"),
|
||||
});
|
64
apps/server/src/modules/app/logo.service.ts
Normal file
64
apps/server/src/modules/app/logo.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
167
apps/server/src/modules/app/routes.ts
Normal file
167
apps/server/src/modules/app/routes.ts
Normal 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)
|
||||
);
|
||||
}
|
78
apps/server/src/modules/app/service.ts
Normal file
78
apps/server/src/modules/app/service.ts
Normal 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 },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
77
apps/server/src/modules/auth/controller.ts
Normal file
77
apps/server/src/modules/auth/controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
36
apps/server/src/modules/auth/dto.ts
Normal file
36
apps/server/src/modules/auth/dto.ts
Normal 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;
|
||||
};
|
148
apps/server/src/modules/auth/routes.ts
Normal file
148
apps/server/src/modules/auth/routes.ts
Normal 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)
|
||||
);
|
||||
}
|
206
apps/server/src/modules/auth/service.ts
Normal file
206
apps/server/src/modules/auth/service.ts
Normal 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);
|
||||
}
|
||||
}
|
42
apps/server/src/modules/config/service.ts
Normal file
42
apps/server/src/modules/config/service.ts
Normal 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 };
|
||||
}, {});
|
||||
}
|
||||
}
|
77
apps/server/src/modules/email/service.ts
Normal file
77
apps/server/src/modules/email/service.ts
Normal 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>
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
242
apps/server/src/modules/file/controller.ts
Normal file
242
apps/server/src/modules/file/controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
21
apps/server/src/modules/file/dto.ts
Normal file
21
apps/server/src/modules/file/dto.ts
Normal 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>;
|
197
apps/server/src/modules/file/routes.ts
Normal file
197
apps/server/src/modules/file/routes.ts
Normal 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)
|
||||
);
|
||||
}
|
51
apps/server/src/modules/file/service.ts
Normal file
51
apps/server/src/modules/file/service.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
8
apps/server/src/modules/health/controller.ts
Normal file
8
apps/server/src/modules/health/controller.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export class HealthController {
|
||||
async check() {
|
||||
return {
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
26
apps/server/src/modules/health/routes.ts
Normal file
26
apps/server/src/modules/health/routes.ts
Normal 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()
|
||||
);
|
||||
}
|
297
apps/server/src/modules/share/controller.ts
Normal file
297
apps/server/src/modules/share/controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
97
apps/server/src/modules/share/dto.ts
Normal file
97
apps/server/src/modules/share/dto.ts
Normal 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>;
|
189
apps/server/src/modules/share/repository.ts
Normal file
189
apps/server/src/modules/share/repository.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
349
apps/server/src/modules/share/routes.ts
Normal file
349
apps/server/src/modules/share/routes.ts
Normal 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)
|
||||
);
|
||||
}
|
360
apps/server/src/modules/share/service.ts
Normal file
360
apps/server/src/modules/share/service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
55
apps/server/src/modules/storage/controller.ts
Normal file
55
apps/server/src/modules/storage/controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
61
apps/server/src/modules/storage/routes.ts
Normal file
61
apps/server/src/modules/storage/routes.ts
Normal 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)
|
||||
);
|
||||
}
|
113
apps/server/src/modules/storage/service.ts
Normal file
113
apps/server/src/modules/storage/service.ts
Normal 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)),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
80
apps/server/src/modules/user/avatar.service.ts
Normal file
80
apps/server/src/modules/user/avatar.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
134
apps/server/src/modules/user/controller.ts
Normal file
134
apps/server/src/modules/user/controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
54
apps/server/src/modules/user/dto.ts
Normal file
54
apps/server/src/modules/user/dto.ts
Normal 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>;
|
17
apps/server/src/modules/user/middleware.ts
Normal file
17
apps/server/src/modules/user/middleware.ts
Normal 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`,
|
||||
});
|
||||
}
|
||||
}
|
72
apps/server/src/modules/user/repository.ts
Normal file
72
apps/server/src/modules/user/repository.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
359
apps/server/src/modules/user/routes.ts
Normal file
359
apps/server/src/modules/user/routes.ts
Normal 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)
|
||||
);
|
||||
}
|
95
apps/server/src/modules/user/service.ts
Normal file
95
apps/server/src/modules/user/service.ts
Normal 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
44
apps/server/src/server.ts
Normal 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);
|
||||
});
|
5
apps/server/src/shared/prisma.ts
Normal file
5
apps/server/src/shared/prisma.ts
Normal 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
14
apps/server/src/types/fastify.d.ts
vendored
Normal 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
27
apps/server/tsconfig.json
Normal 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
1
apps/web/.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:3333
|
1
apps/web/.env.example
Normal file
1
apps/web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
# VITE_API_URL=http://localhost:3333
|
20
apps/web/.eslintignore
Normal file
20
apps/web/.eslintignore
Normal 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
120
apps/web/.eslintrc.json
Normal 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
30
apps/web/.gitignore
vendored
Normal 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
2
apps/web/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
public-hoist-pattern[]=*@heroui/*
|
||||
package-lock=true
|
7
apps/web/.prettierrc.json
Normal file
7
apps/web/.prettierrc.json
Normal 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
20
apps/web/Dockerfile
Normal 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
1
apps/web/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# 🌴 Palmr
|
BIN
apps/web/favicon.ico
Normal file
BIN
apps/web/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 165 KiB |
36
apps/web/index.html
Normal file
36
apps/web/index.html
Normal 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
10
apps/web/orval.config.ts
Normal 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
83
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
1
apps/web/routes.json
Normal file
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
85
apps/web/src/App.tsx
Normal 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;
|
178
apps/web/src/components/general/file-selector.tsx
Normal file
178
apps/web/src/components/general/file-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
54
apps/web/src/components/general/language-switcher.tsx
Normal file
54
apps/web/src/components/general/language-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
133
apps/web/src/components/general/recipient-selector.tsx
Normal file
133
apps/web/src/components/general/recipient-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
29
apps/web/src/components/general/theme-switch.tsx
Normal file
29
apps/web/src/components/general/theme-switch.tsx
Normal 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>
|
||||
);
|
||||
};
|
56
apps/web/src/components/layout/file-manager-layout.tsx
Normal file
56
apps/web/src/components/layout/file-manager-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
28
apps/web/src/components/layout/loading-screen.tsx
Normal file
28
apps/web/src/components/layout/loading-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
104
apps/web/src/components/layout/navbar.tsx
Normal file
104
apps/web/src/components/layout/navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
apps/web/src/components/layout/share-manager-layout.tsx
Normal file
36
apps/web/src/components/layout/share-manager-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
116
apps/web/src/components/modals/create-share-modal.tsx
Normal file
116
apps/web/src/components/modals/create-share-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
115
apps/web/src/components/modals/file-actions-modals.tsx
Normal file
115
apps/web/src/components/modals/file-actions-modals.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
193
apps/web/src/components/modals/file-preview-modal.tsx
Normal file
193
apps/web/src/components/modals/file-preview-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
103
apps/web/src/components/modals/generate-share-link-modal.tsx
Normal file
103
apps/web/src/components/modals/generate-share-link-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
218
apps/web/src/components/modals/share-actions-modals.tsx
Normal file
218
apps/web/src/components/modals/share-actions-modals.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
195
apps/web/src/components/modals/share-details-modal.tsx
Normal file
195
apps/web/src/components/modals/share-details-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
175
apps/web/src/components/modals/upload-file-modal.tsx
Normal file
175
apps/web/src/components/modals/upload-file-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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;
|
||||
}
|
17
apps/web/src/components/route-protector/protected-route.tsx
Normal file
17
apps/web/src/components/route-protector/protected-route.tsx
Normal 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;
|
||||
}
|
110
apps/web/src/components/tables/files-table.tsx
Normal file
110
apps/web/src/components/tables/files-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
149
apps/web/src/components/tables/shares-table.tsx
Normal file
149
apps/web/src/components/tables/shares-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
apps/web/src/components/ui/default-footer.tsx
Normal file
20
apps/web/src/components/ui/default-footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
15
apps/web/src/components/ui/grid-pattern.tsx
Normal file
15
apps/web/src/components/ui/grid-pattern.tsx
Normal 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>
|
||||
);
|
||||
}
|
11
apps/web/src/config/axios.ts
Normal file
11
apps/web/src/config/axios.ts
Normal 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;
|
79
apps/web/src/config/i18n.ts
Normal file
79
apps/web/src/config/i18n.ts
Normal 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;
|
29
apps/web/src/config/site.ts
Normal file
29
apps/web/src/config/site.ts
Normal 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/",
|
||||
},
|
||||
};
|
39
apps/web/src/contexts/ShareContext.tsx
Normal file
39
apps/web/src/contexts/ShareContext.tsx
Normal 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);
|
70
apps/web/src/contexts/app-info-context.tsx
Normal file
70
apps/web/src/contexts/app-info-context.tsx
Normal 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
Reference in New Issue
Block a user