Compare commits

..

10 Commits

Author SHA1 Message Date
Patrick Müller da85d1487c Add claude init file + refactor some security issues 2026-05-02 12:22:03 +02:00
Paddy dc65b49219 Add possibility to add birthdays + repeating events to the API
Jenkins Production Deployment
2025-09-07 18:15:15 +02:00
Paddy 9c45fb11ee Add last modified information to event GET endpoints
Jenkins Production Deployment
2025-05-29 12:51:51 +02:00
Paddy 45dfc22c60 Add endpoint for getting upcoming event and adding swagger docs to all endpoints
Jenkins Production Deployment
2025-04-18 15:05:32 +02:00
Paddy a38fb20e5a Add endpoint that allows to move an event to a different calendar
Jenkins Production Deployment
2024-06-04 11:55:30 +02:00
Paddy cb85e81d67 Add new "choir" calendar and add cascading functionality for calendars
Jenkins Production Deployment
2024-06-04 11:17:28 +02:00
Paddy 59fee19a76 Fix issue with sending mails
Jenkins Production Deployment
2023-12-30 23:11:50 +01:00
Paddy a79e2186a2 Fix activation endpoint HTTP method
Jenkins Production Deployment
2023-12-30 22:55:40 +01:00
Paddy 8f93e1ab7d Add password reset endpoints and mail service for user activation
Jenkins Production Deployment
2023-12-30 22:50:47 +01:00
Paddy 34a4a6664f Fix bug where some fields were not sent back via the api
Jenkins Production Deployment
2023-05-15 20:50:50 +02:00
17 changed files with 2047 additions and 52 deletions
+66
View File
@@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run build # Compile TypeScript → dist/
npm run start # Build and start (tsc && node ./dist/app.js)
npm run debug # Start with DEBUG=* environment variable
npm run test # Run Jest tests with coverage (outputs sonar-report.xml)
```
Run a single test file:
```bash
npx jest test/some.test.ts
```
## Architecture
Express.js REST API in TypeScript with a service-oriented layering. The sole domain is `Calendar`, which organises **events** and **users**.
**Request path:**
1. `app.ts` mounts `Calendar.router.ts` at `/calendar`
2. `Calendar.router.ts` delegates to `events.router.ts` and `users.router.ts`
3. Routers call services; services call the MariaDB pool in `Calendar.db.ts`
**Key layers:**
| Layer | Location |
|---|---|
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` |
| Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) |
| Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
**Auth model:** Users must have a `@nachklang.art` email. After activation they receive a session token (30-day window); the token hash + IP are stored in the DB. Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`) come from `.env`.
**Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
**Calendar types and IDs:** `public` (1), `members` (2), `management` (3), `choir` (4), `birthdays` (5). `credentials.service.ts` enforces which session/credential can read each calendar.
**iCal export:** `icalgenerator.service.ts` converts DB events to RFC 5545 format; reachable via `GET /calendar/events/{calendar}/ical`.
**API docs:** Swagger UI served at `/docs`, generated from JSDoc annotations in the router files.
## Environment
Copy `.env.example` (or create `.env`) with:
```
PORT=
DB_HOST=
DB_USER=
DB_PASSWORD=
CALENDAR_DB=
EMAIL_HOST=
EMAIL_USERNAME=
EMAIL_PASSWORD=
MEMBER_CREDENTIAL=
CHOIR_CREDENTIAL=
MANAGEMENT_CREDENTIAL=
```
## TypeScript config
Strict mode enabled, target ES2016, compiled output in `./dist`, inline source maps. Tests run through `ts-jest` directly against `.ts` sources.
+55
View File
@@ -0,0 +1,55 @@
# Deferred Security Issues
These items were identified during a security review on 2026-05-02 and consciously deferred.
**Must be addressed before opening the application to a larger or public userbase.**
---
## 1. Session credentials in URL query parameters (logged-in users)
**Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers
`sessionId` and `sessionKey` are currently read from query parameters, which means they appear in server access logs, browser history, proxy logs, and `Referer` headers.
**Fix:** Move to request headers (`X-Session-Id` / `X-Session-Key`) or the request body. Requires a corresponding frontend update.
> Note: the shared calendar `password` parameter in query params is intentional (iCal clients don't support headers) and is acceptable for the current setup.
---
## 2. No event ownership check
**Files:** `src/models/calendar/events/events.router.ts`
- `PUT /:eventId` (update)
- `PUT /move/:eventId` (move)
- `DELETE /:eventId` (delete)
Currently any active user can edit, move, or delete any event regardless of who created it. This is acceptable while all users are trusted admins.
**Fix:** When non-admin users are introduced, fetch the event first and verify `event.createdById === user.userId` before allowing the mutation. Add an `isAdmin` flag to the user model to let admins bypass the check.
---
## 3. Activation token has no expiry
**File:** `src/models/calendar/users/users.service.ts``createUser` / `activateUser`
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
**Fix:**
1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`.
3. Check `activation_expires > NOW()` in `activateUser` before accepting the token.
---
## 4. Password reset token has no expiry
**File:** `src/models/calendar/users/users.service.ts``initiatePasswordReset` / `finalizePasswordReset`
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
**Fix:**
1. Add a `pw_reset_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 15 MINUTE` in `initiatePasswordReset`.
3. Check `pw_reset_expires > NOW()` in `finalizePasswordReset` before accepting the token.
+1
View File
@@ -67,6 +67,7 @@ const options = {
swaggerDefinition, swaggerDefinition,
// Paths to files containing OpenAPI definitions // Paths to files containing OpenAPI definitions
apis: [ apis: [
'./src/models/**/*.interface.ts',
'./src/models/**/*.router.ts' './src/models/**/*.router.ts'
] ]
}; };
+37 -3
View File
@@ -15,9 +15,10 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"debug": "^4.3.1", "debug": "^4.3.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"express": "^4.17.1", "express": "^4.18.2",
"guid-typescript": "^1.0.9", "guid-typescript": "^1.0.9",
"mariadb": "^3.0.2", "mariadb": "^3.0.2",
"nodemailer": "^6.9.8",
"random-words": "^1.1.1", "random-words": "^1.1.1",
"swagger-jsdoc": "^6.1.0", "swagger-jsdoc": "^6.1.0",
"swagger-ui-express": "^4.3.0", "swagger-ui-express": "^4.3.0",
@@ -27,8 +28,10 @@
"@types/app-root-path": "^1.2.4", "@types/app-root-path": "^1.2.4",
"@types/bcrypt": "^3.0.1", "@types/bcrypt": "^3.0.1",
"@types/debug": "^4.1.5", "@types/debug": "^4.1.5",
"@types/express": "^4.17.11", "@types/express": "^4.17.15",
"@types/jest": "^28.1.3", "@types/jest": "^28.1.3",
"@types/node": "^18.11.17",
"@types/nodemailer": "^6.4.14",
"@types/random-words": "^1.1.2", "@types/random-words": "^1.1.2",
"@types/swagger-jsdoc": "^6.0.1", "@types/swagger-jsdoc": "^6.0.1",
"@types/swagger-ui-express": "^4.1.3", "@types/swagger-ui-express": "^4.1.3",
@@ -39,7 +42,7 @@
"source-map-support": "^0.5.19", "source-map-support": "^0.5.19",
"ts-jest": "^28.0.5", "ts-jest": "^28.0.5",
"tslint": "^6.1.3", "tslint": "^6.1.3",
"typescript": "^4.1.5" "typescript": "^4.9.4"
} }
}, },
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
@@ -1272,6 +1275,15 @@
"integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==", "integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==",
"dev": true "dev": true
}, },
"node_modules/@types/nodemailer": {
"version": "6.4.14",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz",
"integrity": "sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prettier": { "node_modules/@types/prettier": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz",
@@ -4020,6 +4032,14 @@
"integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==", "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==",
"dev": true "dev": true
}, },
"node_modules/nodemailer": {
"version": "6.9.8",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.8.tgz",
"integrity": "sha512-cfrYUk16e67Ks051i4CntM9kshRYei1/o/Gi8K1d+R34OIs21xdFnW7Pt7EucmVKA0LKtqUGNcjMZ7ehjl49mQ==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nopt": { "node_modules/nopt": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@@ -6592,6 +6612,15 @@
"integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==", "integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==",
"dev": true "dev": true
}, },
"@types/nodemailer": {
"version": "6.4.14",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz",
"integrity": "sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/prettier": { "@types/prettier": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz",
@@ -8691,6 +8720,11 @@
"integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==", "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==",
"dev": true "dev": true
}, },
"nodemailer": {
"version": "6.9.8",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.8.tgz",
"integrity": "sha512-cfrYUk16e67Ks051i4CntM9kshRYei1/o/Gi8K1d+R34OIs21xdFnW7Pt7EucmVKA0LKtqUGNcjMZ7ehjl49mQ=="
},
"nopt": { "nopt": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+2
View File
@@ -22,6 +22,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"guid-typescript": "^1.0.9", "guid-typescript": "^1.0.9",
"mariadb": "^3.0.2", "mariadb": "^3.0.2",
"nodemailer": "^6.9.8",
"random-words": "^1.1.1", "random-words": "^1.1.1",
"swagger-jsdoc": "^6.1.0", "swagger-jsdoc": "^6.1.0",
"swagger-ui-express": "^4.3.0", "swagger-ui-express": "^4.3.0",
@@ -34,6 +35,7 @@
"@types/express": "^4.17.15", "@types/express": "^4.17.15",
"@types/jest": "^28.1.3", "@types/jest": "^28.1.3",
"@types/node": "^18.11.17", "@types/node": "^18.11.17",
"@types/nodemailer": "^6.4.14",
"@types/random-words": "^1.1.2", "@types/random-words": "^1.1.2",
"@types/swagger-jsdoc": "^6.0.1", "@types/swagger-jsdoc": "^6.0.1",
"@types/swagger-ui-express": "^4.1.3", "@types/swagger-ui-express": "^4.1.3",
+38
View File
@@ -0,0 +1,38 @@
import * as nodemailer from 'nodemailer';
export namespace MailService {
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
pool: true,
port: 465,
secure: true,
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD
},
tls: {rejectUnauthorized: false}
});
const mailConfigurations = {
// It should be a string of sender email
from: 'noreply@nachklang.art',
// Comma Separated list of mails
to: 'mail@pmueller.me',
// Subject of Email
subject: '',
// This would be the text of email body
text: ''
};
export const sendMail = async (recipientAddress: string, subject: string, body: string) => {
mailConfigurations.to = recipientAddress;
mailConfigurations.subject = subject;
mailConfigurations.text = body;
await transporter.sendMail(mailConfigurations);
};
}
+2 -1
View File
@@ -10,7 +10,8 @@ export namespace NachklangCalendarDB {
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.CALENDAR_DB, database: process.env.CALENDAR_DB,
connectionLimit: 5 connectionLimit: 5,
autoCommit: false
}); });
export const getConnection = async () => { export const getConnection = async () => {
+33
View File
@@ -16,6 +16,39 @@ calendarRouter.use('/events', eventsRouter);
calendarRouter.use('/users', usersRouter); calendarRouter.use('/users', usersRouter);
/**
* @swagger
* /calendar:
* get:
* summary: Calendar API root endpoint
* description: Returns a welcome message for the Nachklang e.V. Calendar API.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* text/plain:
* schema:
* type: string
* example: Nachklang e.V. Calendar API Endpoint
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
calendarRouter.get('/', async (req: Request, res: Response) => { calendarRouter.get('/', async (req: Request, res: Response) => {
try { try {
res.status(200).send('Nachklang e.V. Calendar API Endpoint'); res.status(200).send('Nachklang e.V. Calendar API Endpoint');
@@ -11,7 +11,7 @@ dotenv.config();
export const checkAdminPrivileges = async (sessionId: string, sessionKey: string, ip: string) => { export const checkAdminPrivileges = async (sessionId: string, sessionKey: string, ip: string) => {
if(sessionId) { if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip); let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user.isActive; return user?.isActive ?? false;
} }
return false; return false;
} }
@@ -23,12 +23,25 @@ export const checkAdminPrivileges = async (sessionId: string, sessionKey: string
export const checkMemberPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => { export const checkMemberPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) { if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip); let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user.isActive; return user?.isActive ?? false;
} }
return password == process.env.MEMBER_CREDENTIAL; return password == process.env.MEMBER_CREDENTIAL;
} }
/**
* Checks if the password gives choir view privileges
* @param password
*/
export const checkChoirPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.CHOIR_CREDENTIAL;
}
/** /**
* Checks if the password gives management view privileges * Checks if the password gives management view privileges
* @param password * @param password
@@ -36,7 +49,7 @@ export const checkMemberPrivileges = async (sessionId: string, sessionKey: strin
export const checkManagementPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => { export const checkManagementPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) { if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip); let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user.isActive; return user?.isActive ?? false;
} }
return password == process.env.MANAGEMENT_CREDENTIAL; return password == process.env.MANAGEMENT_CREDENTIAL;
@@ -48,8 +61,12 @@ export const hasAccess = async (calendarName: string, sessionId: string, session
return true; return true;
case 'members': case 'members':
return await checkMemberPrivileges(sessionId, sessionKey, password, ip); return await checkMemberPrivileges(sessionId, sessionKey, password, ip);
case 'choir':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
case 'management': case 'management':
return await checkManagementPrivileges(sessionId, sessionKey, password, ip); return await checkManagementPrivileges(sessionId, sessionKey, password, ip);
case 'birthdays':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
default: default:
return false; return false;
} }
@@ -1,3 +1,96 @@
/**
* @swagger
* components:
* schemas:
* Event:
* type: object
* required:
* - eventId
* - calendarId
* - uuid
* - name
* - description
* - startDateTime
* - endDateTime
* - createdDate
* - location
* - createdById
* - url
* - wholeDay
* properties:
* eventId:
* type: integer
* description: The unique identifier for the event
* example: 123
* calendarId:
* type: integer
* description: The ID of the calendar this event belongs to
* example: 1
* uuid:
* type: string
* description: A unique UUID for the event
* example: "550e8400-e29b-41d4-a716-446655440000"
* name:
* type: string
* description: The name/title of the event
* example: "Concert at Musikhochschule"
* description:
* type: string
* description: A detailed description of the event
* example: "Annual concert at the Musikhochschule"
* startDateTime:
* type: string
* format: date-time
* description: The start date and time of the event
* example: "2023-06-15T19:00:00.000Z"
* endDateTime:
* type: string
* format: date-time
* description: The end date and time of the event
* example: "2023-06-15T21:00:00.000Z"
* createdDate:
* type: string
* format: date-time
* description: The date and time when the event was created
* example: "2023-05-01T10:00:00.000Z"
* lastModifiedDate:
* type: string
* format: date-time
* example: "2023-05-01T10:00:00.000Z"
* location:
* type: string
* description: The location of the event
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* description: The name of the user who created the event
* example: "John Doe"
* createdById:
* type: integer
* description: The ID of the user who created the event
* example: 456
* lastModifiedBy:
* type: string
* description: The name of the user who last modified the event
* example: "John Doe"
* lastModifiedById:
* type: integer
* description: The ID of the user who last modified the event
* example: 456
* url:
* type: string
* description: A URL with more information about the event
* example: "https://www.nachklang.art/events/concert"
* wholeDay:
* type: boolean
* description: Whether the event lasts the whole day
* example: false
* status:
* type: string
* description: The status of the event
* enum: [PUBLIC, PRIVATE, DRAFT, DELETED]
* example: "PUBLIC"
*/
export interface Event { export interface Event {
eventId: number; eventId: number;
calendarId: number; calendarId: number;
@@ -7,10 +100,14 @@ export interface Event {
startDateTime: Date; startDateTime: Date;
endDateTime: Date; endDateTime: Date;
createdDate: Date; createdDate: Date;
lastModifiedDate?: Date;
location: string; location: string;
createdBy?: string; createdBy?: string;
createdById: number; createdById: number;
lastModifiedBy?: string;
lastModifiedById?: number;
url: string; url: string;
wholeDay: boolean; wholeDay: boolean;
repeatFrequency: string;
status?: string; status?: string;
} }
+777 -6
View File
@@ -24,7 +24,9 @@ export const eventsRouter = express.Router();
export const calendarNames = new Map<string, any>([ export const calendarNames = new Map<string, any>([
['public', {id: 1, name: 'Nachklang_calendar'}], ['public', {id: 1, name: 'Nachklang_calendar'}],
['members', {id: 2, name: 'Nachklang_internal_calendar'}], ['members', {id: 2, name: 'Nachklang_internal_calendar'}],
['management', {id: 3, name: 'Nachklang_management_calendar'}] ['choir', {id: 4, name: 'Nachklang_choir_calendar'}],
['management', {id: 3, name: 'Nachklang_management_calendar'}],
['birthdays', {id: 5, name: 'Nachklang_birthday_calendar'}]
]); ]);
@@ -32,6 +34,77 @@ export const calendarNames = new Map<string, any>([
* Controller Definitions * Controller Definitions
*/ */
/**
* @swagger
* /calendar/events/{calendar}/json:
* get:
* summary: Get all events from a specific calendar in JSON format
* description: Returns all events from the specified calendar in JSON format. Authentication required.
* tags:
* - calendar
* parameters:
* - in: path
* name: calendar
* required: true
* schema:
* type: string
* enum: [public, members, choir, management]
* description: The name of the calendar to get events from
* - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password
* schema:
* type: string
* description: Password for calendar access (if not using session authentication)
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Event'
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Please state the name of the calendar you want events from.
* 403:
* description: Forbidden - no access to the calendar
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Internal Server Error. Try again later.
*/
eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => { eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
try { try {
// Get request params // Get request params
@@ -55,6 +128,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
let user = await UserService.checkSession(sessionId, sessionKey, ip); let user = await UserService.checkSession(sessionId, sessionKey, ip);
// If no user was found, check if the password gives access to the calendar
if(user === null || !user.isActive) { if(user === null || !user.isActive) {
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) { if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
@@ -64,7 +138,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
let events: Event[]; let events: Event[];
if(user.isActive) { if(user?.isActive) {
events = await EventService.getAllEventsAdmin(calendarId); events = await EventService.getAllEventsAdmin(calendarId);
} else { } else {
events = await EventService.getAllEvents(calendarId); events = await EventService.getAllEvents(calendarId);
@@ -73,11 +147,220 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
// Send the events back // Send the events back
res.status(200).send(events); res.status(200).send(events);
} catch (e: any) { } catch (e: any) {
console.log('Error handling a request: ' + e.message); let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({'message': 'Internal Server Error. Try again later.'}); res.status(500).send({'message': 'Internal Server Error. Try again later.'});
} }
}); });
/**
* @swagger
* /calendar/events/{calendar}/json/next:
* get:
* summary: Get the next upcoming event from a calendar
* description: Returns the next upcoming event from the specified calendar. Authentication required.
* tags:
* - calendar
* parameters:
* - in: path
* name: calendar
* required: true
* schema:
* type: string
* enum: [public, members, choir, management]
* description: The name of the calendar to get the next event from
* - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password
* schema:
* type: string
* description: Password for calendar access (if not using session authentication)
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Event'
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Please state the name of the calendar you want events from.
* 403:
* description: Forbidden - no access to the calendar
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: You do not have access to the specified calendar.
* 404:
* description: Not found - no upcoming events
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: No upcoming events found.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) => {
try {
// Get request params
let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
return;
}
if (!Array.from(calendarNames.keys()).includes(calendarName)) {
res.status(400).send({'message': 'Unknown calendar.'});
return;
}
let calendarId: number = calendarNames.get(calendarName)!.id;
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
// Get next upcoming event
let event = await EventService.getNextUpcomingEvent(calendarId);
if (event === null) {
res.status(404).send({'message': 'No upcoming events found.'});
return;
}
// Send the event back
res.status(200).send(event);
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/events/{calendar}/ical:
* get:
* summary: Get all events from a specific calendar in iCal format
* description: Returns all events from the specified calendar in iCal format for calendar applications. Authentication required.
* tags:
* - calendar
* parameters:
* - in: path
* name: calendar
* required: true
* schema:
* type: string
* enum: [public, members, choir, management]
* description: The name of the calendar to get events from
* - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password
* schema:
* type: string
* description: Password for calendar access (if not using session authentication)
* responses:
* 200:
* description: Success - returns iCal file
* content:
* text/calendar:
* schema:
* type: string
* format: binary
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Please state the name of the calendar you want events from.
* 403:
* description: Forbidden - no access to the calendar
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => { eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
try { try {
// Get request params // Get request params
@@ -125,6 +408,120 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
} }
}); });
/**
* @swagger
* /calendar/events:
* post:
* summary: Create a new event
* description: Creates a new event in the specified calendar. Authentication required.
* tags:
* - calendar
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - calendarId
* - name
* - startDateTime
* - endDateTime
* properties:
* calendarId:
* type: integer
* example: 1
* name:
* type: string
* example: "Concert at Musikhochschule"
* description:
* type: string
* example: "Annual concert at the Musikhochschule"
* startDateTime:
* type: string
* format: date-time
* example: "2023-06-15T19:00:00.000Z"
* endDateTime:
* type: string
* format: date-time
* example: "2023-06-15T21:00:00.000Z"
* location:
* type: string
* example: "Musikhochschule, Karlsruhe"
* url:
* type: string
* example: "https://www.nachklang.art/events/concert"
* wholeDay:
* type: boolean
* example: false
* status:
* type: string
* enum: [PUBLIC, PRIVATE, DRAFT, DELETED]
* example: "PUBLIC"
* responses:
* 201:
* description: Event created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Event with id 123 was created successfully.
* eventId:
* type: integer
* example: 123
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Required parameters missing
* 403:
* description: Forbidden - no access to create events
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.post('/', async (req: Request, res: Response) => { eventsRouter.post('/', async (req: Request, res: Response) => {
try { try {
// Get params // Get params
@@ -134,7 +531,7 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
let user = await UserService.checkSession(sessionId, sessionKey, ip); let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user.isActive) { if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -162,6 +559,7 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
createdById: user.userId ?? -1, createdById: user.userId ?? -1,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '',
status: req.body.status ?? 'PUBLIC' status: req.body.status ?? 'PUBLIC'
}; };
@@ -182,6 +580,126 @@ eventsRouter.post('/', async (req: Request, res: Response) => {
} }
}); });
/**
* @swagger
* /calendar/events/{eventId}:
* put:
* summary: Update an existing event
* description: Updates an existing event with the provided data. Authentication required.
* tags:
* - calendar
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* description: The ID of the event to update
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - calendarId
* - name
* - startDateTime
* - endDateTime
* properties:
* calendarId:
* type: integer
* example: 1
* name:
* type: string
* example: "Updated Concert at Musikhochschule"
* description:
* type: string
* example: "Updated annual concert at the Musikhochschule"
* startDateTime:
* type: string
* format: date-time
* example: "2023-06-15T19:00:00.000Z"
* endDateTime:
* type: string
* format: date-time
* example: "2023-06-15T21:00:00.000Z"
* location:
* type: string
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url:
* type: string
* example: "https://www.nachklang.art/events/concert"
* wholeDay:
* type: boolean
* example: false
* status:
* type: string
* enum: [PUBLIC, PRIVATE, DRAFT, DELETED]
* example: "PUBLIC"
* responses:
* 200:
* description: Event updated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Event was successfully updated
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Required parameters missing
* 403:
* description: Forbidden - no access to update events
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.put('/:eventId', async (req: Request, res: Response) => { eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
try { try {
// Get params // Get params
@@ -191,7 +709,7 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
let user = await UserService.checkSession(sessionId, sessionKey, ip); let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user.isActive) { if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -221,6 +739,7 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
createdById: user.userId ?? -1, createdById: user.userId ?? -1,
url: req.body.url ?? '', url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false, wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '',
status: req.body.status ?? 'PUBLIC' status: req.body.status ?? 'PUBLIC'
}; };
@@ -244,6 +763,257 @@ eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
} }
}); });
/**
* @swagger
* /calendar/events/move/{eventId}:
* put:
* summary: Move an event to a different calendar
* description: Moves an existing event to a different calendar. Authentication required.
* tags:
* - calendar
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* description: The ID of the event to move
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - calendarId
* properties:
* calendarId:
* type: integer
* example: 2
* description: The ID of the calendar to move the event to
* name:
* type: string
* example: "Concert at Musikhochschule"
* description:
* type: string
* example: "Annual concert at the Musikhochschule"
* startDateTime:
* type: string
* format: date-time
* example: "2023-06-15T19:00:00.000Z"
* endDateTime:
* type: string
* format: date-time
* example: "2023-06-15T21:00:00.000Z"
* location:
* type: string
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url:
* type: string
* example: "https://www.nachklang.art/events/concert"
* wholeDay:
* type: boolean
* example: false
* status:
* type: string
* enum: [PUBLIC, PRIVATE, DRAFT, DELETED]
* example: "PUBLIC"
* responses:
* 200:
* description: Event moved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Event was successfully moved
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Required parameters missing
* 403:
* description: Forbidden - no access to move events
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
try {
// Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if (
req.params.eventId === undefined ||
req.body.calendarId === undefined
) {
res.status(400).send({'message': 'Required parameters missing'});
return;
}
let event: Event = {
eventId: parseInt(req.params.eventId, 10),
calendarId: req.body.calendarId,
uuid: '',
name: req.body.name,
description: req.body.description ?? '',
startDateTime: new Date(req.body.startDateTime),
endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(),
location: req.body.location ?? '',
createdBy: req.body.createdBy ?? '',
createdById: user.userId ?? -1,
url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '',
status: req.body.status ?? 'PUBLIC'
};
let success = await EventService.moveEvent(event);
if (success) {
res.status(200).send({'message': 'Event was successfully moved'});
} else {
res.status(500).send({'message': 'An error occurred during the moving process. Please try again.'});
}
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/events/{eventId}:
* delete:
* summary: Delete an event
* description: Deletes an existing event. Authentication required.
* tags:
* - calendar
* parameters:
* - in: path
* name: eventId
* required: true
* schema:
* type: integer
* description: The ID of the event to delete
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* responses:
* 200:
* description: Event deleted successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Event was successfully deleted
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Required parameters missing
* 403:
* description: Forbidden - no access to delete events
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.delete('/:eventId', async (req: Request, res: Response) => { eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
try { try {
// Get params // Get params
@@ -253,7 +1023,7 @@ eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
let user = await UserService.checkSession(sessionId, sessionKey, ip); let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user.isActive) { if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'}); res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return; return;
} }
@@ -279,6 +1049,7 @@ eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
createdById: user.userId ?? -1, createdById: user.userId ?? -1,
url: '', url: '',
wholeDay: false, wholeDay: false,
repeatFrequency: '',
status: 'DELETED' status: 'DELETED'
}; };
+124 -17
View File
@@ -14,8 +14,16 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
let eventRows: Event[] = []; let eventRows: Event[] = [];
try { try {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendarsToFetch: number[] = [calendarId];
for(let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendarsToFetch = [...calendarsToFetch, ...includes];
}
const eventsQuery = ` const eventsQuery = `
SELECT e.calendar_id, u.full_name, v.* FROM events e SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN ( INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions FROM event_versions
@@ -25,9 +33,10 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
INNER JOIN event_versions v INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
WHERE e.calendar_id = ? AND v.status = 'PUBLIC' LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
ORDER BY e.event_id`; ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, calendarId); const eventsRes = await conn.query(eventsQuery, [calendarsToFetch]);
for (let row of eventsRes) { for (let row of eventsRes) {
eventRows.push({ eventRows.push({
@@ -39,11 +48,15 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
startDateTime: row.start_datetime, startDateTime: row.start_datetime,
endDateTime: row.end_datetime, endDateTime: row.end_datetime,
createdDate: row.created_date, createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location, location: row.location,
createdBy: row.full_name, createdBy: row.created_by_name,
createdById: row.created_by_id, createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url, url: row.url,
wholeDay: row.whole_day wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
}); });
} }
@@ -56,12 +69,17 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
} }
}; };
/**
* Returns all events for the given calendar for the admin UI (therefore includes admin relevant information and
* ignores the calendar includes
* @param calendarId
*/
export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => { export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
let eventRows: Event[] = []; let eventRows: Event[] = [];
try { try {
const eventsQuery = ` const eventsQuery = `
SELECT e.calendar_id, u.full_name, v.* FROM events e SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN ( INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions FROM event_versions
@@ -71,6 +89,7 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
INNER JOIN event_versions v INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.calendar_id = ? WHERE e.calendar_id = ?
ORDER BY e.event_id`; ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, calendarId); const eventsRes = await conn.query(eventsQuery, calendarId);
@@ -85,11 +104,15 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
startDateTime: row.start_datetime, startDateTime: row.start_datetime,
endDateTime: row.end_datetime, endDateTime: row.end_datetime,
createdDate: row.created_date, createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location, location: row.location,
createdBy: row.full_name, createdBy: row.created_by_name,
createdById: row.created_by_id, createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url, url: row.url,
wholeDay: row.whole_day, wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency,
status: row.status status: row.status
}); });
} }
@@ -110,12 +133,13 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
export const createEvent = async (event: Event): Promise<number> => { export const createEvent = async (event: Event): Promise<number> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction();
let eventUUID = Guid.create().toString(); let eventUUID = Guid.create().toString();
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id'; const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id';
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]); const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]);
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
const versionRes = await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.location, event.url, event.status, event.createdById]); await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]);
await conn.commit(); await conn.commit();
@@ -124,8 +148,6 @@ export const createEvent = async (event: Event): Promise<number> => {
await conn.rollback(); await conn.rollback();
throw err; throw err;
} finally { } finally {
// Return connection
await conn.commit();
await conn.end(); await conn.end();
} }
}; };
@@ -137,8 +159,9 @@ export const createEvent = async (event: Event): Promise<number> => {
export const updateEvent = async (event: Event): Promise<number> => { export const updateEvent = async (event: Event): Promise<number> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?);' await conn.beginTransaction();
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.location, event.url, event.status, event.createdById]); const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]);
await conn.commit(); await conn.commit();
@@ -147,8 +170,6 @@ export const updateEvent = async (event: Event): Promise<number> => {
await conn.rollback(); await conn.rollback();
throw err; throw err;
} finally { } finally {
// Return connection
await conn.commit();
await conn.end(); await conn.end();
} }
}; };
@@ -160,6 +181,7 @@ export const updateEvent = async (event: Event): Promise<number> => {
export const deleteEvent = async (event: Event): Promise<boolean> => { export const deleteEvent = async (event: Event): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_id) VALUES (?,?,?);' const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_id) VALUES (?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]); const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]);
@@ -170,8 +192,93 @@ export const deleteEvent = async (event: Event): Promise<boolean> => {
await conn.rollback(); await conn.rollback();
throw err; throw err;
} finally { } finally {
// Return connection
await conn.commit();
await conn.end(); await conn.end();
} }
}; };
/**
* Moves an event to the specified calendar
* @param event The event to move. Has to have the target calendar set already.
*/
export const moveEvent = async (event: Event): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const eventQuery = 'UPDATE events SET calendar_id = ? WHERE event_id = ?';
const eventRes = await conn.execute(eventQuery, [event.calendarId, event.eventId]);
await conn.commit();
return eventRes.affectedRows === 1;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
/**
* Returns the next upcoming event for the given calendar
* @param calendarId The calendar Id
*/
export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendarsToFetch: number[] = [calendarId];
for(let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendarsToFetch = [...calendarsToFetch, ...includes];
}
const now = new Date();
const eventsQuery = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ?
ORDER BY v.start_datetime ASC
LIMIT 1`;
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch, now]);
if (eventsRes.length === 0) {
return null;
}
const row = eventsRes[0];
return {
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
} as Event;
} catch (err) {
throw err;
} finally {
// Return connection
await conn.end();
}
}
@@ -1,5 +1,9 @@
import {Event} from './event.interface'; import {Event} from './event.interface';
/**
* Interface to external classes - Turns the given events into an ical string
* @param events
*/
export const convertToIcal = async (events: Event[]): Promise<string> => { export const convertToIcal = async (events: Event[]): Promise<string> => {
try { try {
let ical: iCalFile = {body: []}; let ical: iCalFile = {body: []};
@@ -15,6 +19,10 @@ export const convertToIcal = async (events: Event[]): Promise<string> => {
} }
}; };
/**
* Method to serialize an iCalFile object into an ical string
* @param ical
*/
const serializeIcalFile = (ical: iCalFile): string => { const serializeIcalFile = (ical: iCalFile): string => {
let returnString = ''; let returnString = '';
@@ -27,6 +35,10 @@ const serializeIcalFile = (ical: iCalFile): string => {
return returnString; return returnString;
}; };
/**
* Method to serialize a single ical event into an ical event string
* @param icalevent
*/
const serializeIcalEvent = (icalevent: iCalEvent): string => { const serializeIcalEvent = (icalevent: iCalEvent): string => {
let returnString = ''; let returnString = '';
@@ -41,6 +53,7 @@ const serializeIcalEvent = (icalevent: iCalEvent): string => {
returnString += 'DTSTART;TZID=Europe/Berlin:' + icalevent.start; returnString += 'DTSTART;TZID=Europe/Berlin:' + icalevent.start;
returnString += 'DTEND;TZID=Europe/Berlin:' + icalevent.end; returnString += 'DTEND;TZID=Europe/Berlin:' + icalevent.end;
} }
if(!isNullOrBlank(icalevent.repeatFrequency)) returnString += 'RRULE:FREQ=' + icalevent.repeatFrequency;
returnString += 'SUMMARY:' + icalevent.summary; returnString += 'SUMMARY:' + icalevent.summary;
if(!isNullOrBlank(icalevent.description)) returnString += 'DESCRIPTION:' + icalevent.description; if(!isNullOrBlank(icalevent.description)) returnString += 'DESCRIPTION:' + icalevent.description;
if(!isNullOrBlank(icalevent.location)) returnString += 'LOCATION:' + icalevent.location; if(!isNullOrBlank(icalevent.location)) returnString += 'LOCATION:' + icalevent.location;
@@ -50,7 +63,10 @@ const serializeIcalEvent = (icalevent: iCalEvent): string => {
return returnString; return returnString;
}; };
/**
* Method to generate the ical header string
* @param ical
*/
const generateHeaderInfo = (ical: iCalFile) => { const generateHeaderInfo = (ical: iCalFile) => {
ical.header = 'BEGIN:VCALENDAR\n' + ical.header = 'BEGIN:VCALENDAR\n' +
'VERSION:2.0\n' + 'VERSION:2.0\n' +
@@ -78,14 +94,27 @@ const generateHeaderInfo = (ical: iCalFile) => {
'END:VTIMEZONE\n'; 'END:VTIMEZONE\n';
}; };
/**
* Method to generate the ical footer info
* @param ical
*/
const generateFooterInfo = (ical: iCalFile) => { const generateFooterInfo = (ical: iCalFile) => {
ical.footer = 'END:VCALENDAR'; ical.footer = 'END:VCALENDAR';
}; };
/**
* Method to add events to the iCalFile object
* @param ical
* @param event
*/
const addEventToFile = (ical: iCalFile, event: Event) => { const addEventToFile = (ical: iCalFile, event: Event) => {
ical.body.push(createIcalEvent(event)); ical.body.push(createIcalEvent(event));
}; };
/**
* Method to turn an event object into an iCalEvent object
* @param event
*/
const createIcalEvent = (event: Event): iCalEvent => { const createIcalEvent = (event: Event): iCalEvent => {
let description = event.description ? event.description + '\n' : ''; let description = event.description ? event.description + '\n' : '';
let location = event.location ? event.location + '\n' : ''; let location = event.location ? event.location + '\n' : '';
@@ -98,6 +127,7 @@ const createIcalEvent = (event: Event): iCalEvent => {
organizer: event.createdBy + '\n', organizer: event.createdBy + '\n',
start: formatDate(event.startDateTime, event.wholeDay) + '\n', start: formatDate(event.startDateTime, event.wholeDay) + '\n',
end: formatDate(event.endDateTime, event.wholeDay, true) + '\n', end: formatDate(event.endDateTime, event.wholeDay, true) + '\n',
repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '',
summary: event.name + '\n', summary: event.name + '\n',
description: description, description: description,
location: location, location: location,
@@ -107,6 +137,12 @@ const createIcalEvent = (event: Event): iCalEvent => {
}; };
}; };
/**
* Helper method to format dates in a valid iCal format
* @param date
* @param wholeDayFormat
* @param isEndDate
*/
const formatDate = (date: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => { const formatDate = (date: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
let returnString = ''; let returnString = '';
@@ -144,6 +180,7 @@ export interface iCalEvent {
location: string; location: string;
url: string; url: string;
wholeDay: boolean; wholeDay: boolean;
repeatFrequency: string;
footer: string; footer: string;
} }
@@ -1,3 +1,47 @@
/**
* @swagger
* components:
* schemas:
* Session:
* type: object
* required:
* - sessionId
* - userId
* - sessionKey
* - sessionKeyHash
* - lastIP
* properties:
* sessionId:
* type: integer
* description: The unique identifier for the session
* example: 789
* userId:
* type: integer
* description: The ID of the user this session belongs to
* example: 456
* sessionKey:
* type: string
* description: The session key used for authentication
* example: "abc123def456"
* sessionKeyHash:
* type: string
* description: The hashed session key (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* createdDate:
* type: string
* format: date-time
* description: The date and time when the session was created
* example: "2023-05-01T10:00:00.000Z"
* validUntil:
* type: string
* format: date-time
* description: The date and time until when the session is valid
* example: "2023-05-08T10:00:00.000Z"
* lastIP:
* type: string
* description: The last IP address used with this session
* example: "192.168.1.1"
*/
export interface Session { export interface Session {
sessionId: number; sessionId: number;
userId: number; userId: number;
@@ -1,3 +1,38 @@
/**
* @swagger
* components:
* schemas:
* User:
* type: object
* required:
* - userId
* - fullName
* - passwordHash
* - email
* - isActive
* properties:
* userId:
* type: integer
* description: The unique identifier for the user
* example: 456
* fullName:
* type: string
* description: The full name of the user
* example: "John Doe"
* passwordHash:
* type: string
* description: The hashed password of the user (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* email:
* type: string
* format: email
* description: The email address of the user
* example: "john.doe@nachklang.art"
* isActive:
* type: boolean
* description: Whether the user account is active
* example: true
*/
export interface User { export interface User {
userId: number; userId: number;
fullName: string; fullName: string;
+551 -5
View File
@@ -20,6 +20,78 @@ export const usersRouter = express.Router();
* Controller Definitions * Controller Definitions
*/ */
/**
* @swagger
* /calendar/users/register:
* post:
* summary: Register a new user
* description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* - fullName
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* description: Must be an official Nachklang email address
* password:
* type: string
* format: password
* example: securePassword123
* fullName:
* type: string
* example: John Doe
* responses:
* 201:
* description: User registered successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/register // POST users/register
usersRouter.post('/register', async (req: Request, res: Response) => { usersRouter.post('/register', async (req: Request, res: Response) => {
try { try {
@@ -28,12 +100,19 @@ usersRouter.post('/register', async (req: Request, res: Response) => {
const fullName: string = req.body.fullName; const fullName: string = req.body.fullName;
const ip: string = req.socket.remoteAddress ?? ''; const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email) { if (!password || !email || !fullName) {
// Missing // Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'})); res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return; return;
} }
const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/;
if(!emailRegex.test(email)) {
res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'}));
return;
}
// Create the user and a session // Create the user and a session
const session: Session = await UserService.createUser(email, password, fullName, ip); const session: Session = await UserService.createUser(email, password, fullName, ip);
@@ -53,6 +132,189 @@ usersRouter.post('/register', async (req: Request, res: Response) => {
} }
}); });
/**
* @swagger
* /calendar/users/activate:
* get:
* summary: Activate a user account
* description: Activates a user account using the provided user ID and activation token.
* tags:
* - calendar
* parameters:
* - in: query
* name: id
* required: true
* schema:
* type: integer
* description: The ID of the user to activate
* - in: query
* name: token
* required: true
* schema:
* type: string
* description: The activation token sent to the user's email
* responses:
* 200:
* description: User activated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: OK
* message:
* type: string
* example: User activated
* 400:
* description: Bad request - missing parameters or activation failed
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Error activating user. Please contact your administrator.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// GET /users/activate
usersRouter.get('/activate', async (req: Request, res: Response) => {
try {
const userId: number = parseInt(req.query.id as string ?? '-1', 10);
const token: string = req.query.token as string ?? '';
if (!userId || !token) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create the user and a session
const success: boolean = await UserService.activateUser(userId, token);
// Send the session details back to the user
if(success) {
res.status(200).send({
'status': 'OK',
'message': 'User activated'
});
return;
}
res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/login:
* post:
* summary: Login a user
* description: Authenticates a user with the provided email and password and returns a session.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* password:
* type: string
* format: password
* example: securePassword123
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 401:
* description: Unauthorized - invalid credentials
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Wrong username and / or password
* sessionId:
* type: integer
* example: -1
* sessionKey:
* type: string
* example: ""
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/login // POST users/login
usersRouter.post('/login', async (req: Request, res: Response) => { usersRouter.post('/login', async (req: Request, res: Response) => {
try { try {
@@ -67,9 +329,9 @@ usersRouter.post('/login', async (req: Request, res: Response) => {
} }
// Create a session // Create a session
const session: Session = await UserService.login(email, password, ip); const session: Session | null = await UserService.login(email, password, ip);
if (!session.sessionId) { if (!session || !session.sessionId) {
// Error logging in, probably wrong username / password // Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''})); res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''}));
return; return;
@@ -91,6 +353,66 @@ usersRouter.post('/login', async (req: Request, res: Response) => {
} }
}); });
/**
* @swagger
* /calendar/users/checkSessionValid:
* post:
* summary: Check if a session is valid
* description: Checks if the provided session is valid and returns the user information if it is.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* - sessionKey
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* responses:
* 200:
* description: Session is valid
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Unauthorized - invalid session
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: ["Invalid session"]
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/checkSessionValid // POST users/checkSessionValid
usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => { usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
try { try {
@@ -104,9 +426,9 @@ usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
return; return;
} }
const user: User = await UserService.checkSession(session_id, session_key, ip); const user: User | null = await UserService.checkSession(session_id, session_key, ip);
if (!user.userId) { if (!user || !user.userId) {
// Error logging in, probably wrong username / password // Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Invalid session']})); res.status(401).send(JSON.stringify({messages: ['Invalid session']}));
return; return;
@@ -123,3 +445,227 @@ usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
}); });
} }
}); });
/**
* @swagger
* /calendar/users/initiatePasswordReset:
* post:
* summary: Initiates a password reset
* description: Checks if the user exists and if so, initiates a password reset by sending an email to the user.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
*/
usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username given']}));
return;
}
const success: boolean = await UserService.initiatePasswordReset(username);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/finalizePasswordReset:
* post:
* summary: Finalizes the password reset
* description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
* token:
* type: string
* example: 3ccd147f-720b-4e29-a8b7-46b63de31555
* password:
* type: string
* example: ExtremelyBadPassword
*/
usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
const token = req.body.token;
const newPassword = req.body.password;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username, token or password given']}));
return;
}
const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
+127 -16
View File
@@ -4,6 +4,7 @@ import {Guid} from 'guid-typescript';
import {User} from './user.interface'; import {User} from './user.interface';
import {Session} from './session.interface'; import {Session} from './session.interface';
import {NachklangCalendarDB} from '../Calendar.db'; import {NachklangCalendarDB} from '../Calendar.db';
import {MailService} from "../../../common/common.mail.nodemailer";
dotenv.config(); dotenv.config();
@@ -22,14 +23,19 @@ dotenv.config();
export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => { export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction();
// Hash password and generate + hash session key // Hash password and generate + hash session key
const pwHash = bcrypt.hashSync(password, 10); const pwHash = bcrypt.hashSync(password, 10);
const sessionKey = Guid.create().toString(); const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10); const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
const activationToken = Guid.create().toString();
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
// Create user entry in SQL // Create user entry in SQL
const userQuery = 'INSERT INTO users (email, password_hash, full_name) VALUES (?, ?, ?) RETURNING user_id'; const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName]); const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
// Get user id of the created user // Get user id of the created user
let userId: number = -1; let userId: number = -1;
@@ -48,6 +54,9 @@ export const createUser = async (email: string, password: string, fullName: stri
sessionId = row.session_id; sessionId = row.session_id;
} }
// Send email with activation link (after commit so we don't block on email delivery)
await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`);
return { return {
sessionId: sessionId, sessionId: sessionId,
userId: userId, userId: userId,
@@ -56,20 +65,48 @@ export const createUser = async (email: string, password: string, fullName: stri
lastIP: ip lastIP: ip
}; };
} catch (err) { } catch (err) {
await conn.rollback();
throw err; throw err;
} finally { } finally {
// Return connection
await conn.end(); await conn.end();
} }
}; };
export const activateUser = async (userId: number, token: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
const userNameRes = await conn.query(checkTokenQuery, [userId]);
let storedTokenHash = '';
for (const row of userNameRes) {
storedTokenHash = row.activation_token;
}
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
return false;
}
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
const activateRes = await conn.execute(activateQuery, [userId]);
await conn.commit();
return activateRes.affectedRows !== 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
/** /**
* Checks if the given credentials are valid and creates a new session if they are. * Checks if the given credentials are valid and creates a new session if they are.
* Returns the session information in case of a successful login * Returns the session information in case of a successful login
*/ */
export const login = async (email: string, password: string, ip: string): Promise<Session> => { export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction();
// Get saved password hash // Get saved password hash
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?'; const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
const userRows = await conn.query(query, email); const userRows = await conn.query(query, email);
@@ -82,8 +119,7 @@ export const login = async (email: string, password: string, ip: string): Promis
// Check for correct password // Check for correct password
if (!bcrypt.compareSync(password, savedHash)) { if (!bcrypt.compareSync(password, savedHash)) {
// Wrong password, return invalid return null;
return {} as Session;
} }
// Generate + hash session key // Generate + hash session key
@@ -109,9 +145,9 @@ export const login = async (email: string, password: string, ip: string): Promis
lastIP: ip lastIP: ip
}; };
} catch (err) { } catch (err) {
await conn.rollback();
throw err; throw err;
} finally { } finally {
// Return connection
await conn.end(); await conn.end();
} }
}; };
@@ -119,9 +155,11 @@ export const login = async (email: string, password: string, ip: string): Promis
/** /**
* Checks if the given session information are valid and returns the user information if they are * Checks if the given session information are valid and returns the user information if they are
*/ */
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User> => { export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
let conn = await NachklangCalendarDB.getConnection(); let conn = await NachklangCalendarDB.getConnection();
try { try {
await conn.beginTransaction();
// Get saved session key hash // Get saved session key hash
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?'; const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
const sessionRows = await conn.query(query, sessionId); const sessionRows = await conn.query(query, sessionId);
@@ -136,30 +174,26 @@ export const checkSession = async (sessionId: string, sessionKey: string, ip: st
// Check for correct key // Check for correct key
if (!bcrypt.compareSync(sessionKey, savedHash)) { if (!bcrypt.compareSync(sessionKey, savedHash)) {
// Wrong key, return invalid return null;
return {} as User;
} }
// Check if the session is still valid // Check if the session is still valid
if (validUntil <= new Date()) { if (validUntil <= new Date()) {
// Session expired, return invalid return null;
return {} as User;
} }
// Update session entry in SQL // Update session entry in SQL
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?'; const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
const userIdRes = await conn.query(updateSessionsQuery, [ip, sessionId]); await conn.query(updateSessionsQuery, [ip, sessionId]);
await conn.commit(); await conn.commit();
// Get the other required user information // Get the other required user information
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?'; const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
const userRows = await conn.query(userQuery, userId); const userRows = await conn.query(userQuery, userId);
let username = '';
let email = ''; let email = '';
let fullName = ''; let fullName = '';
let is_active = false; let is_active = false;
for (const row of userRows) { for (const row of userRows) {
username = row.username;
email = row.email; email = row.email;
fullName = row.full_name; fullName = row.full_name;
is_active = row.is_active; is_active = row.is_active;
@@ -174,9 +208,86 @@ export const checkSession = async (sessionId: string, sessionKey: string, ip: st
isActive: is_active isActive: is_active
}; };
} catch (err) { } catch (err) {
await conn.rollback();
throw err; throw err;
} finally { } finally {
// Return connection
await conn.end(); await conn.end();
} }
}; };
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
const userNameRes = await conn.query(checkUsernameQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: number = -1;
let fullName: string = '';
for(let row of userNameRes) {
userId = row.user_id;
fullName = row.full_Name;
}
let resetToken = Guid.create().toString();
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
if(updateRes.affectedRows === 0) {
return false;
}
await conn.commit();
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
const userNameRes = await conn.query(checkTokenQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: string = '';
let tokenHash: string = '';
for(let row of userNameRes) {
userId = row.user_id;
tokenHash = row.pw_reset_token_hash;
}
if(!bcrypt.compareSync(token, tokenHash)) {
return false;
}
const pwHash = bcrypt.hashSync(newPassword, 10);
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
if(updateRes.affectedRows > 0) {
await conn.commit();
return true;
}
return false;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}