Compare commits

...

25 Commits

Author SHA1 Message Date
pierrbt
36fa76563e Add clickable link to reverse share's shares 2023-06-29 20:07:00 +02:00
pierrbt
f96ac5e4ba Merge branch 'stonith404:main' into main 2023-06-29 19:38:58 +02:00
Elias Schneider
447c86f1c9 chore: remove backend Dockerfile 2023-06-28 15:45:54 +02:00
pierrbt
1466240461 feat: Adding more informations on My Shares page (table and modal) (#174)
* Adding an information button to the shares and corrected MyShare interface

* Adding other informations and disk usage

* Adding description, disk usage

* Add case if the expiration is never

* Adding file size and better UI

* UI changes to Information Modal

* Adding description to the My Shares page

* Ran format

* Remove string type

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Remove string type check

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Remove string type conversion

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Variable name changes

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Remove color

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Requested changes made

* Ran format

* Adding MediaQuery

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-06-26 08:22:15 +02:00
pierrbt
bbbf10d233 Merge branch 'stonith404:main' into main 2023-06-23 21:09:24 +02:00
pierrbt
348852cfa4 feat: Adding the possibility of copying the link by clicking text and icons (#171) 2023-06-23 20:07:49 +02:00
pierrbt
e9d1a9abb6 Remove useless import 2023-06-23 16:23:32 +02:00
pierrbt
8fdba0ca7c Run format 2023-06-23 16:22:57 +02:00
pierrbt
e5718700bc Set only a single click on the text, to avoid multiple notifications if the user wants to select it 2023-06-23 16:21:53 +02:00
pierrbt
e40a0c844c Creating CopyTextField component and adding it to showCompletedUpload and Shaere Modal 2023-06-23 16:14:52 +02:00
pierrbt
e647746c93 Update frontend/src/components/upload/modals/showCompletedUploadModal.tsx
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2023-06-23 15:53:50 +02:00
pierrbt
9be77826e9 Formatting 2023-06-23 01:39:22 +02:00
pierrbt
a9bb05c4da Last updated 2023-06-23 00:05:53 +02:00
pierrbt
e1a9f2a27c Remove useless import 2023-06-22 23:18:09 +02:00
pierrbt
ba62c13cfa Removing the textClicked, because it was only source of disturbance 2023-06-22 23:17:50 +02:00
pierrbt
3de744d5e9 Adding a check when link is clicked, and now also on the text 2023-06-22 23:16:45 +02:00
pierrbt
61608cfe2d Adding LinkClicked to the copy button 2023-06-22 22:40:43 +02:00
pierrbt
db755ef300 Adding copy to keyboard when clicking the link 2023-06-22 22:36:56 +02:00
Elias Schneider
932496a121 release: 0.15.0 2023-05-09 09:18:31 +02:00
Elias Schneider
0c7b2a8e70 docs: add environment variables to the README 2023-05-09 09:18:02 +02:00
Elias Schneider
1df5c7123e feat: allow to configure clamav with environment variables 2023-05-09 08:45:56 +02:00
Elias Schneider
2dc0fc9332 refactor: improve logging 2023-05-09 08:45:30 +02:00
Elias Schneider
98c0de78e8 feat: add env variables for port, database url and data dir 2023-05-05 11:37:02 +02:00
Elias Schneider
5132d177b8 feat: add healthcheck endpoint 2023-04-27 22:31:06 +02:00
Elias Schneider
e5071cba12 feat: configure ports, db url and api url with env variables 2023-04-25 23:39:57 +02:00
35 changed files with 340 additions and 139 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ yarn-error.log*
# env file # env file
.env .env
!/backend/prisma/.env
# vercel # vercel
.vercel .vercel

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
/backend/src/constants.ts

View File

@@ -1,3 +1,13 @@
## [0.15.0](https://github.com/stonith404/pingvin-share/compare/v0.14.1...v0.15.0) (2023-05-09)
### Features
* add env variables for port, database url and data dir ([98c0de7](https://github.com/stonith404/pingvin-share/commit/98c0de78e8a73e3e5bf0928226cfb8a024b566a1))
* add healthcheck endpoint ([5132d17](https://github.com/stonith404/pingvin-share/commit/5132d177b8ab4e00a7e701e9956222fa2352d42c))
* allow to configure clamav with environment variables ([1df5c71](https://github.com/stonith404/pingvin-share/commit/1df5c7123e4ca8695f4f1b7d49f46cdf147fb920))
* configure ports, db url and api url with env variables ([e5071cb](https://github.com/stonith404/pingvin-share/commit/e5071cba1204093197b72e18d024b484e72e360a))
### [0.14.1](https://github.com/stonith404/pingvin-share/compare/v0.14.0...v0.14.1) (2023-04-07) ### [0.14.1](https://github.com/stonith404/pingvin-share/compare/v0.14.0...v0.14.1) (2023-04-07)

View File

@@ -31,7 +31,7 @@ RUN npm run build && npm prune --production
# Stage 5: Final image # Stage 5: Final image
FROM node:19-slim AS runner FROM node:19-slim AS runner
ENV NODE_ENV=docker ENV NODE_ENV=docker
RUN apt-get update && apt-get install -y openssl RUN apt-get update && apt-get install -y curl openssl
WORKDIR /opt/app/frontend WORKDIR /opt/app/frontend
COPY --from=frontend-builder /opt/app/public ./public COPY --from=frontend-builder /opt/app/public ./public
@@ -47,4 +47,6 @@ COPY --from=backend-builder /opt/app/package.json ./
WORKDIR /opt/app WORKDIR /opt/app
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s CMD curl -f http://localhost:3000/api/health || exit 1
CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod CMD cp -rn /tmp/img /opt/app/frontend/public && node frontend/server.js & cd backend && npm run prod

View File

@@ -117,9 +117,30 @@ docker compose up -d
pm2 restart pingvin-share-frontend pm2 restart pingvin-share-frontend
``` ```
### Custom branding ### Configuration
You can change the name and the logo of the app by visiting the admin configuration page. You can customize Pingvin Share by going to the configuration page in your admin dashboard.
#### Environment variables
For installation specific configuration, you can use environment variables. The following variables are available:
##### Backend
| Variable | Default Value | Description |
| ---------------- | -------------------------------------------------- | -------------------------------------- |
| `PORT` | `8080` | The port on which the backend listens. |
| `DATABASE_URL` | `file:../data/pingvin-share.db?connection_limit=1` | The URL of the SQLite database. |
| `DATA_DIRECTORY` | `./data` | The directory where data is stored. |
| `CLAMAV_HOST` | `127.0.0.1` | The IP address of the ClamAV server. |
| `CLAMAV_PORT` | `3310` | The port number of the ClamAV server. |
##### Frontend
| Variable | Default Value | Description |
| --------- | ----------------------- | ---------------------------------------- |
| `PORT` | `3000` | The port on which the frontend listens. |
| `API_URL` | `http://localhost:8080` | The URL of the backend for the frontend. |
## 🖤 Contribute ## 🖤 Contribute

View File

@@ -1,22 +0,0 @@
FROM node:18 AS deps
WORKDIR /opt/app
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
RUN npx prisma generate
FROM node:18 As build
WORKDIR /opt/app
COPY . .
COPY --from=deps /opt/app/node_modules ./node_modules
RUN npm run build
FROM node:18 As runner
WORKDIR /opt/app
COPY --from=build /opt/app/node_modules ./node_modules
COPY --from=build /opt/app/dist ./dist
COPY --from=build /opt/app/prisma ./prisma
COPY --from=deps /opt/app/package.json ./
CMD npm run prod

View File

@@ -1,12 +1,12 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.14.1", "version": "0.15.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.14.1", "version": "0.15.0",
"dependencies": { "dependencies": {
"@nestjs/common": "^9.3.9", "@nestjs/common": "^9.3.9",
"@nestjs/config": "^2.3.1", "@nestjs/config": "^2.3.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share-backend", "name": "pingvin-share-backend",
"version": "0.14.1", "version": "0.15.0",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"dev": "cross-env NODE_ENV=development nest start --watch", "dev": "cross-env NODE_ENV=development nest start --watch",

2
backend/prisma/.env Normal file
View File

@@ -0,0 +1,2 @@
#This file is only used to set a default value for the database url
DATABASE_URL="file:../data/pingvin-share.db"

View File

@@ -4,7 +4,7 @@ generator client {
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = "file:../data/pingvin-share.db" url = env("DATABASE_URL")
} }
model User { model User {

View File

@@ -1,6 +1,5 @@
import { Prisma, PrismaClient } from "@prisma/client"; import { Prisma, PrismaClient } from "@prisma/client";
import * as crypto from "crypto"; import * as crypto from "crypto";
const configVariables: ConfigVariables = { const configVariables: ConfigVariables = {
internal: { internal: {
jwtSecret: { jwtSecret: {
@@ -162,7 +161,15 @@ type ConfigVariables = {
}; };
}; };
const prisma = new PrismaClient(); const prisma = new PrismaClient({
datasources: {
db: {
url:
process.env.DATABASE_URL ||
"file:../data/pingvin-share.db?connection_limit=1",
},
},
});
async function seedConfigVariables() { async function seedConfigVariables() {
for (const [category, configVariablesOfCategory] of Object.entries( for (const [category, configVariablesOfCategory] of Object.entries(

View File

@@ -1,20 +1,22 @@
import { Injectable } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import * as NodeClam from "clamscan"; import * as NodeClam from "clamscan";
import * as fs from "fs"; import * as fs from "fs";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CLAMAV_HOST, CLAMAV_PORT, SHARE_DIRECTORY } from "../constants";
const clamscanConfig = { const clamscanConfig = {
clamdscan: { clamdscan: {
host: process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1", host: CLAMAV_HOST,
port: 3310, port: CLAMAV_PORT,
localFallback: false, localFallback: false,
}, },
preference: "clamdscan", preference: "clamdscan",
}; };
@Injectable() @Injectable()
export class ClamScanService { export class ClamScanService {
private readonly logger = new Logger(ClamScanService.name);
constructor( constructor(
private fileService: FileService, private fileService: FileService,
private prisma: PrismaService private prisma: PrismaService
@@ -23,11 +25,11 @@ export class ClamScanService {
private ClamScan: Promise<NodeClam | null> = new NodeClam() private ClamScan: Promise<NodeClam | null> = new NodeClam()
.init(clamscanConfig) .init(clamscanConfig)
.then((res) => { .then((res) => {
console.log("ClamAV is active"); this.logger.log("ClamAV is active");
return res; return res;
}) })
.catch(() => { .catch(() => {
console.log("ClamAV is not active"); this.logger.log("ClamAV is not active");
return null; return null;
}); });
@@ -39,14 +41,14 @@ export class ClamScanService {
const infectedFiles = []; const infectedFiles = [];
const files = fs const files = fs
.readdirSync(`./data/uploads/shares/${shareId}`) .readdirSync(`${SHARE_DIRECTORY}/${shareId}`)
.filter((file) => file != "archive.zip"); .filter((file) => file != "archive.zip");
for (const fileId of files) { for (const fileId of files) {
const { isInfected } = await clamScan const { isInfected } = await clamScan
.isInfected(`./data/uploads/shares/${shareId}/${fileId}`) .isInfected(`${SHARE_DIRECTORY}/${shareId}/${fileId}`)
.catch(() => { .catch(() => {
console.log("ClamAV is not active"); this.logger.log("ClamAV is not active");
return { isInfected: false }; return { isInfected: false };
}); });
@@ -78,7 +80,7 @@ export class ClamScanService {
}, },
}); });
console.log( this.logger.warn(
`Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)` `Share ${shareId} deleted because it contained ${infectedFiles.length} malicious file(s)`
); );
} }

5
backend/src/constants.ts Normal file
View File

@@ -0,0 +1,5 @@
export const DATA_DIRECTORY = process.env.DATA_DIRECTORY || "./data";
export const SHARE_DIRECTORY = `${DATA_DIRECTORY}/uploads/shares`
export const DATABASE_URL = process.env.DATABASE_URL || "file:../data/pingvin-share.db?connection_limit=1";
export const CLAMAV_HOST = process.env.CLAMAV_HOST || (process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1");
export const CLAMAV_PORT = parseInt(process.env.CLAMAV_PORT) || 3310;

View File

@@ -11,6 +11,7 @@ import * as fs from "fs";
import * as mime from "mime-types"; import * as mime from "mime-types";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable() @Injectable()
export class FileService { export class FileService {
@@ -39,7 +40,7 @@ export class FileService {
let diskFileSize: number; let diskFileSize: number;
try { try {
diskFileSize = fs.statSync( diskFileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk` `${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`
).size; ).size;
} catch { } catch {
diskFileSize = 0; diskFileSize = 0;
@@ -78,18 +79,18 @@ export class FileService {
} }
fs.appendFileSync( fs.appendFileSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`, `${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
buffer buffer
); );
const isLastChunk = chunk.index == chunk.total - 1; const isLastChunk = chunk.index == chunk.total - 1;
if (isLastChunk) { if (isLastChunk) {
fs.renameSync( fs.renameSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`, `${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
`./data/uploads/shares/${shareId}/${file.id}` `${SHARE_DIRECTORY}/${shareId}/${file.id}`
); );
const fileSize = fs.statSync( const fileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}` `${SHARE_DIRECTORY}/${shareId}/${file.id}`
).size; ).size;
await this.prisma.file.create({ await this.prisma.file.create({
data: { data: {
@@ -111,9 +112,7 @@ export class FileService {
if (!fileMetaData) throw new NotFoundException("File not found"); if (!fileMetaData) throw new NotFoundException("File not found");
const file = fs.createReadStream( const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
`./data/uploads/shares/${shareId}/${fileId}`
);
return { return {
metaData: { metaData: {
@@ -126,13 +125,13 @@ export class FileService {
} }
async deleteAllFiles(shareId: string) { async deleteAllFiles(shareId: string) {
await fs.promises.rm(`./data/uploads/shares/${shareId}`, { await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
recursive: true, recursive: true,
force: true, force: true,
}); });
} }
getZip(shareId: string) { getZip(shareId: string) {
return fs.createReadStream(`./data/uploads/shares/${shareId}/archive.zip`); return fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/archive.zip`);
} }
} }

View File

@@ -1,13 +1,16 @@
import { Injectable } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule"; import { Cron } from "@nestjs/schedule";
import * as fs from "fs"; import * as fs from "fs";
import * as moment from "moment"; import * as moment from "moment";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service"; import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable() @Injectable()
export class JobsService { export class JobsService {
private readonly logger = new Logger(JobsService.name);
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private reverseShareService: ReverseShareService, private reverseShareService: ReverseShareService,
@@ -34,8 +37,9 @@ export class JobsService {
await this.fileService.deleteAllFiles(expiredShare.id); await this.fileService.deleteAllFiles(expiredShare.id);
} }
if (expiredShares.length > 0) if (expiredShares.length > 0) {
console.log(`job: deleted ${expiredShares.length} expired shares`); this.logger.log(`Deleted ${expiredShares.length} expired shares`);
}
} }
@Cron("0 * * * *") @Cron("0 * * * *")
@@ -50,10 +54,11 @@ export class JobsService {
await this.reverseShareService.remove(expiredReverseShare.id); await this.reverseShareService.remove(expiredReverseShare.id);
} }
if (expiredReverseShares.length > 0) if (expiredReverseShares.length > 0) {
console.log( this.logger.log(
`job: deleted ${expiredReverseShares.length} expired reverse shares` `Deleted ${expiredReverseShares.length} expired reverse shares`
); );
}
} }
@Cron("0 0 * * *") @Cron("0 0 * * *")
@@ -61,31 +66,31 @@ export class JobsService {
let filesDeleted = 0; let filesDeleted = 0;
const shareDirectories = fs const shareDirectories = fs
.readdirSync("./data/uploads/shares", { withFileTypes: true }) .readdirSync(SHARE_DIRECTORY, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory()) .filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name); .map((dirent) => dirent.name);
for (const shareDirectory of shareDirectories) { for (const shareDirectory of shareDirectories) {
const temporaryFiles = fs const temporaryFiles = fs
.readdirSync(`./data/uploads/shares/${shareDirectory}`) .readdirSync(`${SHARE_DIRECTORY}/${shareDirectory}`)
.filter((file) => file.endsWith(".tmp-chunk")); .filter((file) => file.endsWith(".tmp-chunk"));
for (const file of temporaryFiles) { for (const file of temporaryFiles) {
const stats = fs.statSync( const stats = fs.statSync(
`./data/uploads/shares/${shareDirectory}/${file}` `${SHARE_DIRECTORY}/${shareDirectory}/${file}`
); );
const isOlderThanOneDay = moment(stats.mtime) const isOlderThanOneDay = moment(stats.mtime)
.add(1, "day") .add(1, "day")
.isBefore(moment()); .isBefore(moment());
if (isOlderThanOneDay) { if (isOlderThanOneDay) {
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`); fs.rmSync(`${SHARE_DIRECTORY}/${shareDirectory}/${file}`);
filesDeleted++; filesDeleted++;
} }
} }
} }
console.log(`job: deleted ${filesDeleted} temporary files`); this.logger.log(`Deleted ${filesDeleted} temporary files`);
} }
@Cron("0 * * * *") @Cron("0 * * * *")
@@ -107,7 +112,8 @@ export class JobsService {
const deletedTokensCount = const deletedTokensCount =
refreshTokenCount + loginTokenCount + resetPasswordTokenCount; refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
if (deletedTokensCount > 0) if (deletedTokensCount > 0) {
console.log(`job: deleted ${deletedTokensCount} expired refresh tokens`); this.logger.log(`Deleted ${deletedTokensCount} expired refresh tokens`);
}
} }
} }

View File

@@ -6,6 +6,7 @@ import * as bodyParser from "body-parser";
import * as cookieParser from "cookie-parser"; import * as cookieParser from "cookie-parser";
import * as fs from "fs"; import * as fs from "fs";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { DATA_DIRECTORY } from "./constants";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
@@ -16,7 +17,9 @@ async function bootstrap() {
app.use(cookieParser()); app.use(cookieParser());
app.set("trust proxy", true); app.set("trust proxy", true);
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true }); await fs.promises.mkdir(`${DATA_DIRECTORY}/uploads/_temp`, {
recursive: true,
});
app.setGlobalPrefix("api"); app.setGlobalPrefix("api");
@@ -30,6 +33,6 @@ async function bootstrap() {
SwaggerModule.setup("api/swagger", app, document); SwaggerModule.setup("api/swagger", app, document);
} }
await app.listen(8080); await app.listen(parseInt(process.env.PORT) || 8080);
} }
bootstrap(); bootstrap();

View File

@@ -1,5 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { DATABASE_URL } from "../constants";
@Injectable() @Injectable()
export class PrismaService extends PrismaClient { export class PrismaService extends PrismaClient {
@@ -7,7 +8,7 @@ export class PrismaService extends PrismaClient {
super({ super({
datasources: { datasources: {
db: { db: {
url: "file:../data/pingvin-share.db?connection_limit=1", url: DATABASE_URL,
}, },
}, },
}); });

View File

@@ -1,7 +1,13 @@
import { Expose, plainToClass } from "class-transformer"; import { Expose, plainToClass, Type } from "class-transformer";
import { ShareDTO } from "./share.dto"; import { ShareDTO } from "./share.dto";
import {FileDTO} from "../../file/dto/file.dto";
import {OmitType} from "@nestjs/swagger";
export class MyShareDTO extends ShareDTO { export class MyShareDTO extends OmitType(ShareDTO, [
"files",
"from",
"fromList",
] as const) {
@Expose() @Expose()
views: number; views: number;
@@ -11,6 +17,10 @@ export class MyShareDTO extends ShareDTO {
@Expose() @Expose()
recipients: string[]; recipients: string[];
@Expose()
@Type(() => OmitType(FileDTO, ["share", "from"] as const))
files: Omit<FileDTO, "share" | "from">[];
from(partial: Partial<MyShareDTO>) { from(partial: Partial<MyShareDTO>) {
return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true }); return plainToClass(MyShareDTO, partial, { excludeExtraneousValues: true });
} }
@@ -20,4 +30,4 @@ export class MyShareDTO extends ShareDTO {
plainToClass(MyShareDTO, part, { excludeExtraneousValues: true }) plainToClass(MyShareDTO, part, { excludeExtraneousValues: true })
); );
} }
} }

View File

@@ -16,6 +16,7 @@ import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service"; import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service"; import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { SHARE_DIRECTORY } from "../constants";
import { CreateShareDTO } from "./dto/createShare.dto"; import { CreateShareDTO } from "./dto/createShare.dto";
@Injectable() @Injectable()
@@ -65,7 +66,7 @@ export class ShareService {
} }
} }
fs.mkdirSync(`./data/uploads/shares/${share.id}`, { fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
recursive: true, recursive: true,
}); });
@@ -99,7 +100,7 @@ export class ShareService {
} }
async createZip(shareId: string) { async createZip(shareId: string) {
const path = `./data/uploads/shares/${shareId}`; const path = `${SHARE_DIRECTORY}/${shareId}`;
const files = await this.prisma.file.findMany({ where: { shareId } }); const files = await this.prisma.file.findMany({ where: { shareId } });
const archive = archiver("zip", { const archive = archiver("zip", {
@@ -194,7 +195,7 @@ export class ShareService {
orderBy: { orderBy: {
expiration: "desc", expiration: "desc",
}, },
include: { recipients: true }, include: { recipients: true, files: true },
}); });
return shares.map((share) => { return shares.map((share) => {

View File

@@ -19,4 +19,7 @@ module.exports = withPWA({
output: "standalone", env: { output: "standalone", env: {
VERSION: version, VERSION: version,
}, },
serverRuntimeConfig: {
apiURL: process.env.API_URL ?? 'http://localhost:8080',
},
}); });

View File

@@ -1,12 +1,12 @@
{ {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.14.1", "version": "0.15.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.14.1", "version": "0.15.0",
"dependencies": { "dependencies": {
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.10.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share-frontend", "name": "pingvin-share-frontend",
"version": "0.14.1", "version": "0.15.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",

View File

@@ -0,0 +1,85 @@
import { Text, Divider, Progress, Stack, Group, Flex } from "@mantine/core";
import { ModalsContextProps } from "@mantine/modals/lib/context";
import { MyShare } from "../../types/share.type";
import moment from "moment";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import CopyTextField from "../upload/CopyTextField";
import { FileMetaData } from "../../types/File.type";
const showShareInformationsModal = (
modals: ModalsContextProps,
share: MyShare,
appUrl: string,
maxShareSize: number
) => {
const link = `${appUrl}/share/${share.id}`;
let shareSize: number = 0;
for (let file of share.files as FileMetaData[])
shareSize += parseInt(file.size);
const formattedShareSize = byteToHumanSizeString(shareSize);
const formattedMaxShareSize = byteToHumanSizeString(maxShareSize);
const shareSizeProgress = (shareSize / maxShareSize) * 100;
const formattedCreatedAt = moment(share.createdAt).format("LLL");
const formattedExpiration =
moment(share.expiration).unix() === 0
? "Never"
: moment(share.expiration).format("LLL");
return modals.openModal({
title: "Share informations",
children: (
<Stack align="stretch" spacing="md">
<Text size="sm" color="lightgray">
<b>ID:</b> {share.id}
</Text>
<Text size="sm" color="lightgray">
<b>Description:</b> {share.description || "No description"}
</Text>
<Text size="sm" color="lightgray">
<b>Created at:</b> {formattedCreatedAt}
</Text>
<Text size="sm" color="lightgray">
<b>Expires at:</b> {formattedExpiration}
</Text>
<Divider />
<CopyTextField link={link} />
<Divider />
<Text size="sm" color="lightgray">
<b>Size:</b> {formattedShareSize} / {formattedMaxShareSize} (
{shareSizeProgress.toFixed(1)}%)
</Text>
<Flex align="center" justify="center">
{shareSize / maxShareSize < 0.1 && (
<Text size="xs" color="lightgray" style={{ marginRight: "4px" }}>
{formattedShareSize}
</Text>
)}
<Progress
value={shareSizeProgress}
label={shareSize / maxShareSize >= 0.1 ? formattedShareSize : ""}
style={{ width: shareSize / maxShareSize < 0.1 ? "70%" : "80%" }}
size="xl"
radius="xl"
/>
<Text size="xs" color="lightgray" style={{ marginLeft: "4px" }}>
{formattedMaxShareSize}
</Text>
</Flex>
</Stack>
),
});
};
export default showShareInformationsModal;

View File

@@ -116,10 +116,9 @@ const TextPreview = () => {
const [text, setText] = useState<string | null>(null); const [text, setText] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
api.get(`/shares/${shareId}/files/${fileId}?download=false`).then((res) => { api
console.log(res.data); .get(`/shares/${shareId}/files/${fileId}?download=false`)
setText(res.data); .then((res) => setText(res.data));
});
}, [shareId, fileId]); }, [shareId, fileId]);
return ( return (

View File

@@ -1,9 +1,7 @@
import { ActionIcon, Button, Stack, TextInput } from "@mantine/core"; import { Button, Stack } from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import { TbCopy } from "react-icons/tb"; import CopyTextField from "../../upload/CopyTextField";
import toast from "../../../utils/toast.util";
const showCompletedReverseShareModal = ( const showCompletedReverseShareModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
@@ -26,28 +24,11 @@ const Body = ({
link: string; link: string;
getReverseShares: () => void; getReverseShares: () => void;
}) => { }) => {
const clipboard = useClipboard({ timeout: 500 });
const modals = useModals(); const modals = useModals();
return ( return (
<Stack align="stretch"> <Stack align="stretch">
<TextInput <CopyTextField link={link} />
readOnly
variant="filled"
value={link}
rightSection={
window.isSecureContext && (
<ActionIcon
onClick={() => {
clipboard.copy(link);
toast.success("Your link was copied to the keyboard.");
}}
>
<TbCopy />
</ActionIcon>
)
}
/>
<Button <Button
onClick={() => { onClick={() => {

View File

@@ -0,0 +1,48 @@
import { useRef, useState } from "react";
import toast from "../../utils/toast.util";
import { ActionIcon, TextInput } from "@mantine/core";
import { TbCheck, TbCopy } from "react-icons/tb";
import { useClipboard } from "@mantine/hooks";
function CopyTextField(props: { link: string }) {
const clipboard = useClipboard({ timeout: 500 });
const [checkState, setCheckState] = useState(false);
const [textClicked, setTextClicked] = useState(false);
const timerRef = useRef<number | ReturnType<typeof setTimeout> | undefined>(
undefined
);
const copyLink = () => {
clipboard.copy(props.link);
toast.success("Your link was copied to the keyboard.");
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setCheckState(false);
}, 1500);
setCheckState(true);
};
return (
<TextInput
readOnly
label="Link"
variant="filled"
value={props.link}
onClick={() => {
if (!textClicked) {
copyLink();
setTextClicked(true);
}
}}
rightSection={
window.isSecureContext && (
<ActionIcon onClick={copyLink}>
{checkState ? <TbCheck /> : <TbCopy />}
</ActionIcon>
)
}
/>
);
}
export default CopyTextField;

View File

@@ -1,12 +1,10 @@
import { ActionIcon, Button, Stack, Text, TextInput } from "@mantine/core"; import { Button, Stack, Text } from "@mantine/core";
import { useClipboard } from "@mantine/hooks";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import moment from "moment"; import moment from "moment";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { TbCopy } from "react-icons/tb";
import { Share } from "../../../types/share.type"; import { Share } from "../../../types/share.type";
import toast from "../../../utils/toast.util"; import CopyTextField from "../CopyTextField";
const showCompletedUploadModal = ( const showCompletedUploadModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
@@ -23,30 +21,14 @@ const showCompletedUploadModal = (
}; };
const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => { const Body = ({ share, appUrl }: { share: Share; appUrl: string }) => {
const clipboard = useClipboard({ timeout: 500 });
const modals = useModals(); const modals = useModals();
const router = useRouter(); const router = useRouter();
const link = `${appUrl}/share/${share.id}`; const link = `${appUrl}/share/${share.id}`;
return ( return (
<Stack align="stretch"> <Stack align="stretch">
<TextInput <CopyTextField link={link} />
readOnly
variant="filled"
value={link}
rightSection={
window.isSecureContext && (
<ActionIcon
onClick={() => {
clipboard.copy(link);
toast.success("Your link was copied to the keyboard.");
}}
>
<TbCopy />
</ActionIcon>
)
}
/>
<Text <Text
size="xs" size="xs"
sx={(theme) => ({ sx={(theme) => ({

View File

@@ -22,7 +22,7 @@ export async function middleware(request: NextRequest) {
// Get config from backend // Get config from backend
const config = await ( const config = await (
await fetch("http://localhost:8080/api/configs") await fetch(`${request.nextUrl.origin}/api/configs`)
).json(); ).json();
const getConfig = (key: string) => { const getConfig = (key: string) => {

View File

@@ -11,6 +11,7 @@ import axios from "axios";
import { getCookie, setCookie } from "cookies-next"; import { getCookie, setCookie } from "cookies-next";
import { GetServerSidePropsContext } from "next"; import { GetServerSidePropsContext } from "next";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
import getConfig from "next/config";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Header from "../components/header/Header"; import Header from "../components/header/Header";
@@ -117,6 +118,8 @@ function App({ Component, pageProps }: AppProps) {
// Fetch user and config variables on server side when the first request is made // Fetch user and config variables on server side when the first request is made
// These will get passed as a page prop to the App component and stored in the contexts // These will get passed as a page prop to the App component and stored in the contexts
App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => { App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
const { apiURL } = getConfig().serverRuntimeConfig;
let pageProps: { let pageProps: {
user?: CurrentUser; user?: CurrentUser;
configVariables?: Config[]; configVariables?: Config[];
@@ -130,15 +133,13 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
if (ctx.req) { if (ctx.req) {
const cookieHeader = ctx.req.headers.cookie; const cookieHeader = ctx.req.headers.cookie;
pageProps.user = await axios(`http://localhost:8080/api/users/me`, { pageProps.user = await axios(`${apiURL}/api/users/me`, {
headers: { cookie: cookieHeader }, headers: { cookie: cookieHeader },
}) })
.then((res) => res.data) .then((res) => res.data)
.catch(() => null); .catch(() => null);
pageProps.configVariables = ( pageProps.configVariables = (await axios(`${apiURL}/api/configs`)).data;
await axios(`http://localhost:8080/api/configs`)
).data;
pageProps.route = ctx.req.url; pageProps.route = ctx.req.url;
} }

View File

@@ -1,6 +1,7 @@
import { import {
Accordion, Accordion,
ActionIcon, ActionIcon,
Anchor,
Box, Box,
Button, Button,
Center, Center,
@@ -34,6 +35,8 @@ const MyShares = () => {
const [reverseShares, setReverseShares] = useState<MyReverseShare[]>(); const [reverseShares, setReverseShares] = useState<MyReverseShare[]>();
const appUrl = config.get("general.appUrl");
const getReverseShares = () => { const getReverseShares = () => {
shareService shareService
.getMyReverseShares() .getMyReverseShares()
@@ -119,9 +122,11 @@ const MyShares = () => {
<Accordion.Panel> <Accordion.Panel>
{reverseShare.shares.map((share) => ( {reverseShare.shares.map((share) => (
<Group key={share.id} mb={4}> <Group key={share.id} mb={4}>
<Text maw={120} truncate> <Anchor href={`${appUrl}/share/${share.id}`}>
{share.id} <Text maw={120} truncate>
</Text> {share.id}
</Text>
</Anchor>
<ActionIcon <ActionIcon
color="victoria" color="victoria"
variant="light" variant="light"
@@ -129,9 +134,7 @@ const MyShares = () => {
onClick={() => { onClick={() => {
if (window.isSecureContext) { if (window.isSecureContext) {
clipboard.copy( clipboard.copy(
`${config.get( `${appUrl}/share/${share.id}`
"general.appUrl"
)}/share/${share.id}`
); );
toast.success( toast.success(
"The share link was copied to the keyboard." "The share link was copied to the keyboard."

View File

@@ -4,6 +4,7 @@ import {
Button, Button,
Center, Center,
Group, Group,
MediaQuery,
Space, Space,
Stack, Stack,
Table, Table,
@@ -15,7 +16,7 @@ import { useModals } from "@mantine/modals";
import moment from "moment"; import moment from "moment";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TbLink, TbTrash } from "react-icons/tb"; import { TbLink, TbTrash, TbInfoCircle } from "react-icons/tb";
import showShareLinkModal from "../../components/account/showShareLinkModal"; import showShareLinkModal from "../../components/account/showShareLinkModal";
import CenterLoader from "../../components/core/CenterLoader"; import CenterLoader from "../../components/core/CenterLoader";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
@@ -23,6 +24,7 @@ import useConfig from "../../hooks/config.hook";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import { MyShare } from "../../types/share.type"; import { MyShare } from "../../types/share.type";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
import showShareInformationsModal from "../../components/account/showShareInformationsModal";
const MyShares = () => { const MyShares = () => {
const modals = useModals(); const modals = useModals();
@@ -60,6 +62,10 @@ const MyShares = () => {
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<MediaQuery smallerThan="md" styles={{ display: "none" }}>
<th>Description</th>
</MediaQuery>
<th>Visitors</th> <th>Visitors</th>
<th>Expires at</th> <th>Expires at</th>
<th></th> <th></th>
@@ -69,6 +75,18 @@ const MyShares = () => {
{shares.map((share) => ( {shares.map((share) => (
<tr key={share.id}> <tr key={share.id}>
<td>{share.id}</td> <td>{share.id}</td>
<MediaQuery smallerThan="sm" styles={{ display: "none" }}>
<td
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "300px",
}}
>
{share.description || ""}
</td>
</MediaQuery>
<td>{share.views}</td> <td>{share.views}</td>
<td> <td>
{moment(share.expiration).unix() === 0 {moment(share.expiration).unix() === 0
@@ -77,6 +95,21 @@ const MyShares = () => {
</td> </td>
<td> <td>
<Group position="right"> <Group position="right">
<ActionIcon
color="blue"
variant="light"
size={25}
onClick={() => {
showShareInformationsModal(
modals,
share,
config.get("general.appUrl"),
parseInt(config.get("share.maxSize"))
);
}}
>
<TbInfoCircle />
</ActionIcon>
<ActionIcon <ActionIcon
color="victoria" color="victoria"
variant="light" variant="light"

View File

@@ -1,5 +1,6 @@
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import httpProxyMiddleware from "next-http-proxy-middleware"; import httpProxyMiddleware from "next-http-proxy-middleware";
import getConfig from "next/config";
export const config = { export const config = {
api: { api: {
@@ -8,11 +9,13 @@ export const config = {
}, },
}; };
const { apiURL } = getConfig().serverRuntimeConfig;
export default (req: NextApiRequest, res: NextApiResponse) => { export default (req: NextApiRequest, res: NextApiResponse) => {
return httpProxyMiddleware(req, res, { return httpProxyMiddleware(req, res, {
headers: { headers: {
"X-Forwarded-For": req.socket?.remoteAddress ?? "", "X-Forwarded-For": req.socket?.remoteAddress ?? "",
}, },
target: "http://localhost:8080", target: apiURL,
}); });
}; };

View File

@@ -0,0 +1,14 @@
import axios from "axios";
import { NextApiRequest, NextApiResponse } from "next";
import getConfig from "next/config";
const { apiURL } = getConfig().serverRuntimeConfig;
export default async (req: NextApiRequest, res: NextApiResponse) => {
const apiStatus = await axios
.get(`${apiURL}/api/configs`)
.then(() => "OK")
.catch(() => "ERROR");
res.status(apiStatus == "OK" ? 200 : 500).send(apiStatus);
};

View File

@@ -24,7 +24,7 @@ export type ShareMetaData = {
export type MyShare = Share & { export type MyShare = Share & {
views: number; views: number;
cratedAt: Date; createdAt: Date;
}; };
export type MyReverseShare = { export type MyReverseShare = {

View File

@@ -1,6 +1,6 @@
{ {
"name": "pingvin-share", "name": "pingvin-share",
"version": "0.14.1", "version": "0.15.0",
"scripts": { "scripts": {
"format": "cd frontend && npm run format && cd ../backend && npm run format", "format": "cd frontend && npm run format && cd ../backend && npm run format",
"lint": "cd frontend && npm run lint && cd ../backend && npm run lint", "lint": "cd frontend && npm run lint && cd ../backend && npm run lint",