Compare commits

..

7 Commits

Author SHA1 Message Date
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
26 changed files with 142 additions and 61 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ yarn-error.log*
# env file
.env
!/backend/prisma/.env
# 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)

View File

@@ -31,7 +31,7 @@ RUN npm run build && npm prune --production
# Stage 5: Final image
FROM node:19-slim AS runner
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
COPY --from=frontend-builder /opt/app/public ./public
@@ -47,4 +47,6 @@ COPY --from=backend-builder /opt/app/package.json ./
WORKDIR /opt/app
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

View File

@@ -117,9 +117,30 @@ docker compose up -d
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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "pingvin-share-backend",
"version": "0.14.1",
"version": "0.15.0",
"scripts": {
"build": "nest build",
"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 {
provider = "sqlite"
url = "file:../data/pingvin-share.db"
url = env("DATABASE_URL")
}
model User {

View File

@@ -1,6 +1,5 @@
import { Prisma, PrismaClient } from "@prisma/client";
import * as crypto from "crypto";
const configVariables: ConfigVariables = {
internal: {
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() {
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 fs from "fs";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import { CLAMAV_HOST, CLAMAV_PORT, SHARE_DIRECTORY } from "../constants";
const clamscanConfig = {
clamdscan: {
host: process.env.NODE_ENV == "docker" ? "clamav" : "127.0.0.1",
port: 3310,
host: CLAMAV_HOST,
port: CLAMAV_PORT,
localFallback: false,
},
preference: "clamdscan",
};
@Injectable()
export class ClamScanService {
private readonly logger = new Logger(ClamScanService.name);
constructor(
private fileService: FileService,
private prisma: PrismaService
@@ -23,11 +25,11 @@ export class ClamScanService {
private ClamScan: Promise<NodeClam | null> = new NodeClam()
.init(clamscanConfig)
.then((res) => {
console.log("ClamAV is active");
this.logger.log("ClamAV is active");
return res;
})
.catch(() => {
console.log("ClamAV is not active");
this.logger.log("ClamAV is not active");
return null;
});
@@ -39,14 +41,14 @@ export class ClamScanService {
const infectedFiles = [];
const files = fs
.readdirSync(`./data/uploads/shares/${shareId}`)
.readdirSync(`${SHARE_DIRECTORY}/${shareId}`)
.filter((file) => file != "archive.zip");
for (const fileId of files) {
const { isInfected } = await clamScan
.isInfected(`./data/uploads/shares/${shareId}/${fileId}`)
.isInfected(`${SHARE_DIRECTORY}/${shareId}/${fileId}`)
.catch(() => {
console.log("ClamAV is not active");
this.logger.log("ClamAV is not active");
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)`
);
}

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 { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable()
export class FileService {
@@ -39,7 +40,7 @@ export class FileService {
let diskFileSize: number;
try {
diskFileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`
).size;
} catch {
diskFileSize = 0;
@@ -78,18 +79,18 @@ export class FileService {
}
fs.appendFileSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
buffer
);
const isLastChunk = chunk.index == chunk.total - 1;
if (isLastChunk) {
fs.renameSync(
`./data/uploads/shares/${shareId}/${file.id}.tmp-chunk`,
`./data/uploads/shares/${shareId}/${file.id}`
`${SHARE_DIRECTORY}/${shareId}/${file.id}.tmp-chunk`,
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
);
const fileSize = fs.statSync(
`./data/uploads/shares/${shareId}/${file.id}`
`${SHARE_DIRECTORY}/${shareId}/${file.id}`
).size;
await this.prisma.file.create({
data: {
@@ -111,9 +112,7 @@ export class FileService {
if (!fileMetaData) throw new NotFoundException("File not found");
const file = fs.createReadStream(
`./data/uploads/shares/${shareId}/${fileId}`
);
const file = fs.createReadStream(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
return {
metaData: {
@@ -126,13 +125,13 @@ export class FileService {
}
async deleteAllFiles(shareId: string) {
await fs.promises.rm(`./data/uploads/shares/${shareId}`, {
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
recursive: true,
force: true,
});
}
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 * as fs from "fs";
import * as moment from "moment";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { SHARE_DIRECTORY } from "../constants";
@Injectable()
export class JobsService {
private readonly logger = new Logger(JobsService.name);
constructor(
private prisma: PrismaService,
private reverseShareService: ReverseShareService,
@@ -34,8 +37,9 @@ export class JobsService {
await this.fileService.deleteAllFiles(expiredShare.id);
}
if (expiredShares.length > 0)
console.log(`job: deleted ${expiredShares.length} expired shares`);
if (expiredShares.length > 0) {
this.logger.log(`Deleted ${expiredShares.length} expired shares`);
}
}
@Cron("0 * * * *")
@@ -50,10 +54,11 @@ export class JobsService {
await this.reverseShareService.remove(expiredReverseShare.id);
}
if (expiredReverseShares.length > 0)
console.log(
`job: deleted ${expiredReverseShares.length} expired reverse shares`
if (expiredReverseShares.length > 0) {
this.logger.log(
`Deleted ${expiredReverseShares.length} expired reverse shares`
);
}
}
@Cron("0 0 * * *")
@@ -61,31 +66,31 @@ export class JobsService {
let filesDeleted = 0;
const shareDirectories = fs
.readdirSync("./data/uploads/shares", { withFileTypes: true })
.readdirSync(SHARE_DIRECTORY, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
for (const shareDirectory of shareDirectories) {
const temporaryFiles = fs
.readdirSync(`./data/uploads/shares/${shareDirectory}`)
.readdirSync(`${SHARE_DIRECTORY}/${shareDirectory}`)
.filter((file) => file.endsWith(".tmp-chunk"));
for (const file of temporaryFiles) {
const stats = fs.statSync(
`./data/uploads/shares/${shareDirectory}/${file}`
`${SHARE_DIRECTORY}/${shareDirectory}/${file}`
);
const isOlderThanOneDay = moment(stats.mtime)
.add(1, "day")
.isBefore(moment());
if (isOlderThanOneDay) {
fs.rmSync(`./data/uploads/shares/${shareDirectory}/${file}`);
fs.rmSync(`${SHARE_DIRECTORY}/${shareDirectory}/${file}`);
filesDeleted++;
}
}
}
console.log(`job: deleted ${filesDeleted} temporary files`);
this.logger.log(`Deleted ${filesDeleted} temporary files`);
}
@Cron("0 * * * *")
@@ -107,7 +112,8 @@ export class JobsService {
const deletedTokensCount =
refreshTokenCount + loginTokenCount + resetPasswordTokenCount;
if (deletedTokensCount > 0)
console.log(`job: deleted ${deletedTokensCount} expired refresh tokens`);
if (deletedTokensCount > 0) {
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 fs from "fs";
import { AppModule } from "./app.module";
import { DATA_DIRECTORY } from "./constants";
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
@@ -16,7 +17,9 @@ async function bootstrap() {
app.use(cookieParser());
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");
@@ -30,6 +33,6 @@ async function bootstrap() {
SwaggerModule.setup("api/swagger", app, document);
}
await app.listen(8080);
await app.listen(parseInt(process.env.PORT) || 8080);
}
bootstrap();

View File

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

View File

@@ -16,6 +16,7 @@ import { EmailService } from "src/email/email.service";
import { FileService } from "src/file/file.service";
import { PrismaService } from "src/prisma/prisma.service";
import { ReverseShareService } from "src/reverseShare/reverseShare.service";
import { SHARE_DIRECTORY } from "../constants";
import { CreateShareDTO } from "./dto/createShare.dto";
@Injectable()
@@ -65,7 +66,7 @@ export class ShareService {
}
}
fs.mkdirSync(`./data/uploads/shares/${share.id}`, {
fs.mkdirSync(`${SHARE_DIRECTORY}/${share.id}`, {
recursive: true,
});
@@ -99,7 +100,7 @@ export class ShareService {
}
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 archive = archiver("zip", {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import axios from "axios";
import { getCookie, setCookie } from "cookies-next";
import { GetServerSidePropsContext } from "next";
import type { AppProps } from "next/app";
import getConfig from "next/config";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
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
// These will get passed as a page prop to the App component and stored in the contexts
App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
const { apiURL } = getConfig().serverRuntimeConfig;
let pageProps: {
user?: CurrentUser;
configVariables?: Config[];
@@ -130,15 +133,13 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => {
if (ctx.req) {
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 },
})
.then((res) => res.data)
.catch(() => null);
pageProps.configVariables = (
await axios(`http://localhost:8080/api/configs`)
).data;
pageProps.configVariables = (await axios(`${apiURL}/api/configs`)).data;
pageProps.route = ctx.req.url;
}

View File

@@ -1,5 +1,6 @@
import { NextApiRequest, NextApiResponse } from "next";
import httpProxyMiddleware from "next-http-proxy-middleware";
import getConfig from "next/config";
export const config = {
api: {
@@ -8,11 +9,13 @@ export const config = {
},
};
const { apiURL } = getConfig().serverRuntimeConfig;
export default (req: NextApiRequest, res: NextApiResponse) => {
return httpProxyMiddleware(req, res, {
headers: {
"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

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