mirror of
https://github.com/kyantech/Palmr.git
synced 2025-11-02 04:53:26 +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