47 Commits
1.0.1 ... 1.2.0

Author SHA1 Message Date
89b2a853d3 Bump version to 1.2.0 in package.json, package-lock.json, and federation.js
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m20s
Build & Push Docker Image / build (release) Successful in 6m23s
2026-02-28 23:15:13 +01:00
ed8fb134ad Enhance logging in API calls and request/response middleware with sensitive data filtering and compact format
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m28s
2026-02-28 21:24:25 +01:00
8e18149ad1 Add request/response logging middleware to enhance auditing and debugging
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
2026-02-28 20:43:18 +01:00
1cff066c17 Refactor theme and language validation to use basic format checks instead of allowlists
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m22s
2026-02-28 20:30:11 +01:00
c281628fdc Update README and configuration to replace RSA with Ed25519 for federation security
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m30s
2026-02-28 20:19:59 +01:00
2831f80ab4 Update README.md to reflect new features, security enhancements, and environment variable requirements
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m24s
2026-02-28 20:11:14 +01:00
1fb999d73b Refactor theme validation to dynamically import themes from the source directory
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m20s
2026-02-28 20:02:15 +01:00
7466f3513d Enhance security and validation across multiple routes:
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
- Escape XML and HTML special characters to prevent injection attacks.
- Implement rate limiting for various endpoints to mitigate abuse.
- Add validation for email formats, password lengths, and field limits.
- Ensure proper access control for recordings and room management.
2026-02-28 19:49:29 +01:00
616442a82a Add TRUST_PROXY configuration for reverse proxy settings
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m28s
2026-02-28 19:25:08 +01:00
5cb8201fb5 Update Redis configuration to allow unlimited retries and disable offline queue
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m5s
2026-02-28 14:17:01 +01:00
3556aaede7 Add DragonflyDB integration for JWT revocation and implement rate limiting for authentication routes
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m14s
2026-02-28 13:37:27 +01:00
ed97587248 Add federated room detail page and improve address parsing in invites
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m18s
2026-02-27 17:42:37 +01:00
9814150ba8 Add verification resend timestamp and cooldown handling for email verification
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m13s
2026-02-27 17:23:22 +01:00
4d6a09c3fd Improve email verification error handling in registration and resend verification endpoints
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-02-27 17:21:01 +01:00
4d1245f358 fix mail
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m8s
2026-02-27 17:09:14 +01:00
4d0756d864 keep original presentation name
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m0s
2026-02-27 16:51:17 +01:00
ab52ca4529 Add email verification handling and resend functionality to login
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m2s
2026-02-27 16:49:24 +01:00
15eed76ab4 fix typo
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m13s
2026-02-27 16:40:23 +01:00
a7af7d0e6f Add presentation upload and management features to room functionality
Some checks failed
Build & Push Docker Image / build (push) Failing after 1m11s
2026-02-27 16:37:57 +01:00
9be9938f02 Add display name support for user management and update related components
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m2s
2026-02-27 16:29:23 +01:00
d781022b63 add timeouts
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m8s
2026-02-27 16:12:41 +01:00
2762df3e57 Add default theme management to branding settings and admin interface
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m14s
2026-02-27 15:54:41 +01:00
d7d7991ff0 more details with federation
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-02-27 15:51:46 +01:00
e5b6c225e9 New federation features
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m58s
2026-02-27 15:24:18 +01:00
83849bd2f6 fix federation
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m0s
2026-02-27 15:06:38 +01:00
ffb7a45bfc Try arm64 build
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m5s
2026-02-27 14:50:09 +01:00
3b4c1c383e Try arm64 build
Some checks failed
Build & Push Docker Image / build (push) Failing after 1m19s
2026-02-27 14:46:36 +01:00
ef5790b44d Try making arm64 image
Some checks failed
Build & Push Docker Image / build (push) Failing after 1m9s
2026-02-27 14:42:40 +01:00
0d48f52d3b Add action for federation
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m8s
2026-02-27 12:55:55 +01:00
c5a6a15731 feat: implement federation for inter-instance meeting invitations with dedicated API, UI, and configuration. 2026-02-27 12:53:20 +01:00
a35b708cbf add theme in index.css
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m11s
2026-02-26 18:11:59 +01:00
0b9bba2285 try to add new theme
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m10s
2026-02-26 14:49:29 +01:00
32cc4d724b Add defaultWelcome lang string
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m19s
2026-02-26 09:53:29 +01:00
1e19aa24dd Use create call instead of join call with loginURL
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m10s
2026-02-26 09:20:41 +01:00
a6e400b6b7 joinUrl -> loginURL
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m9s
2026-02-26 08:50:47 +01:00
52a2e2260c Use joinUrl parameter
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m11s
2026-02-26 08:44:40 +01:00
7426ae8088 Update language, add LICENSE and README
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m9s
2026-02-24 21:04:19 +01:00
2ef6a9f30b bump version 1.0.1 -> 1.1.0
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m11s
Build & Push Docker Image / build (release) Successful in 1m12s
2026-02-24 20:36:10 +01:00
8be973a166 Add mail verification and use .env insteads of environment in compose
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
2026-02-24 20:35:08 +01:00
3898bf1b4b fix branding again
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m7s
2026-02-24 19:52:34 +01:00
69a3c83436 fix branding
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m8s
2026-02-24 19:47:23 +01:00
cd98ee4cc7 add branding option
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m10s
2026-02-24 19:43:59 +01:00
d8dcb6e628 Add sharing rooms
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m8s
2026-02-24 19:32:57 +01:00
a150bd1447 Add guest link to welcome message
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m7s
2026-02-24 19:19:19 +01:00
49769d4b51 change guest access
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m8s
2026-02-24 19:14:55 +01:00
9001aea8cd add avatar support for BBB
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m10s
2026-02-24 19:05:41 +01:00
cf74ed31af some fixing
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m6s
2026-02-24 18:55:21 +01:00
50 changed files with 5119 additions and 373 deletions

View File

@@ -12,9 +12,38 @@ JWT_SECRET=your-super-secret-jwt-key-change-this
# DATABASE_URL=postgres://user:password@localhost:5432/redlight
DATABASE_URL=
POSTGRES_USER=redlight
POSTGRES_PASSWORD=redlight
POSTGRES_DB=redlight
# SQLite file path (only used when DATABASE_URL is not set)
# SQLITE_PATH=./redlight.db
# Dragonfly (Redis-compatible in-memory database) Configuration
REDIS_URL=redis://dragonfly:6379
# Default Admin Account (created on first run)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin123
# SMTP Configuration (for email verification)
# If not set, registration works without email verification
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASS=your-smtp-password
SMTP_FROM=noreply@example.com
# App URL (used for verification links, auto-detected if not set)
# APP_URL=https://your-domain.com
# Reverse Proxy trust depth (express 'trust proxy' setting)
# loopback = trust only 127.0.0.1 / ::1 (default)
# Use a number for proxy hops (e.g. 1), or a specific IP/CIDR.
# TRUST_PROXY=loopback
# Federation (inter-instance meeting invitations)
# Set both values to enable federation between Redlight instances
# FEDERATION_DOMAIN=redlight.example.com
# RSA Private Key for signing outbound invitations (automatically generated if missing on startup)
# FEDERATION_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgk...\n-----END PRIVATE KEY-----"

View File

@@ -4,12 +4,16 @@ on:
push:
branches:
- main
- federation-try
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
env:
# Set to 'true' if you need to compile native modules from source (e.g. better-sqlite3 on arm)
BUILD_FROM_SOURCE: 'false'
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -18,6 +22,9 @@ jobs:
id: registry
run: echo "host=$(echo ${{ github.server_url }} | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -44,6 +51,10 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
# Build multi-arch images for amd64 and arm64
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BUILD_FROM_SOURCE=${{ env.BUILD_FROM_SOURCE }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,36 +1,40 @@
# ── Stage 1: Build frontend ──────────────────────────────────────────────────
FROM node:20-alpine AS builder
FROM node:20-bullseye-slim AS builder
WORKDIR /app
# Install build tools and sqlite headers for native modules
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 build-essential libsqlite3-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# Produce production node_modules (compile native modules here for the target arch)
RUN npm ci --omit=dev && npm cache clean --force
# ── Stage 2: Production image ───────────────────────────────────────────────
FROM node:20-alpine
# better-sqlite3 needs build tools for native compilation
RUN apk add --no-cache python3 make g++
FROM node:20-bullseye-slim
# Allow forcing build from source (useful when prebuilt binaries are not available)
ARG BUILD_FROM_SOURCE=false
ENV npm_config_build_from_source=${BUILD_FROM_SOURCE}
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev && npm cache clean --force
# Remove build tools after install to keep image smaller
RUN apk del python3 make g++
# Copy production node_modules and built frontend from builder stage
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Copy server code
COPY server/ ./server/
# Copy built frontend from builder stage
COPY --from=builder /app/dist ./dist
# Create uploads directory
RUN mkdir -p uploads/avatars
RUN mkdir -p uploads/avatars uploads/branding
ENV NODE_ENV=production
ENV PORT=3001

232
LICENSE Normal file
View File

@@ -0,0 +1,232 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
redlight
Copyright (C) 2026 Michelle
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
redlight Copyright (C) 2026 Michelle
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.

434
README.md Normal file
View File

@@ -0,0 +1,434 @@
# 🔴 Redlight
A modern, self-hosted BigBlueButton frontend with beautiful themes, federation, and powerful features.
![Node.js](https://img.shields.io/badge/Node.js-20+-green)
![React](https://img.shields.io/badge/React-18+-blue)
![License](https://img.shields.io/badge/License-MIT-yellow)
![BigBlueButton](https://img.shields.io/badge/BigBlueButton-Compatible-red)
## ✨ Features
### Core Features
- 🎥 **Video Conferencing** Integrated BigBlueButton support for professional video meetings
- 🎨 **15+ Themes** Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox, and more
- 📝 **Room Management** Create unlimited rooms with custom settings, access codes, and moderator codes
- 🔐 **User Management** Registration, login, role-based access control (Admin/User)
- 📹 **Recording Management** View, publish, and delete meeting recordings per room
- 🌍 **Multi-Language Support** German (Deutsch) and English built-in, easily extensible
- ✉️ **Email Verification** Optional SMTP-based email verification for user registration
- 👤 **User Profiles** Customizable avatars, themes, and language preferences
- 📱 **Responsive Design** Works seamlessly on mobile, tablet, and desktop
- 🌐 **Federation** Invite users from remote Redlight instances via Ed25519-signed messages
- 🐉 **DragonflyDB / Redis** JWT blacklisting for secure token revocation on logout
### Admin Features
- 👥 **User Administration** Manage users and roles
- 🏢 **Branding Customization** Custom app name, logos, and default theme
- 📊 **Dashboard** Overview of system statistics
- 🔧 **Settings Management** System-wide configuration
### Room Features
- 🔑 **Access Codes** Restrict room access with optional passwords
- 🔐 **Moderator Codes** Separate code to grant moderator privileges
- 🚪 **Guest Access** Allow unauthenticated users to join meetings (rate-limited)
- ⏱️ **Max Participants** Set limits on concurrent participants
- 🎤 **Mute on Join** Automatically mute new participants
-**Approval Mode** Require moderator approval for participants
- 🎙️ **Anyone Can Start** Allow participants to start the meeting
- 📹 **Recording Settings** Control whether meetings are recorded
- 📊 **Presentation Upload** Upload PDF, PPTX, ODP, or image files as default slides
- 🤝 **Room Sharing** Share rooms with other registered users
### Security
- 🛡️ **Comprehensive Rate Limiting** Login, register, profile, avatar, guest-join, and federation endpoints
- 🔒 **Input Validation** Email format, field length limits, ID format checks, color format validation
- 🕐 **Timing-Safe Comparisons** Access codes and moderator codes compared with `crypto.timingSafeEqual`
- 📏 **Streaming Upload Limits** Avatar (5 MB) and presentation (50 MB) uploads reject early without buffering
- 🧹 **XSS Prevention** HTML-escaped emails, XML-escaped BBB parameters, SVG sanitization
- 🔐 **JWT Blacklist** Token revocation via DragonflyDB/Redis on logout
- 🌐 **CORS Restriction** Locked to `APP_URL` in production
- ⚙️ **Configurable Trust Proxy** `TRUST_PROXY` env var for reverse proxy setups
### Developer Features
- 🐳 **Docker Support** Easy deployment with Docker Compose (includes PostgreSQL + DragonflyDB)
- 🗄️ **Database Flexibility** SQLite (default) or PostgreSQL support
- 🔌 **REST API** Comprehensive API for custom integrations
- 📦 **Open Source** Full source code transparency
- 🛠️ **Self-Hosted** Complete data privacy and control
---
## 📊 Comparison: Redlight vs Greenlight
| Feature | Redlight | Greenlight |
|---------|----------|-----------|
| **Theme System** | 15+ customizable themes | Limited theming |
| **Federation** | ✅ Cross-instance invites | ❌ Not supported |
| **Language Support** | Multi-language ready | Multi-language ready |
| **UI Framework** | React + Tailwind (Modern) | Rails-based (Traditional) |
| **User Preferences** | Theme, language, avatar | Limited customization |
| **Database Options** | SQLite / PostgreSQL | PostgreSQL only |
| **Docker** | ✅ Supported | ✅ Supported |
| **Admin Dashboard** | Modern React UI | Legacy Rails interface |
| **Room Sharing** | ✅ Share rooms with users | ✅ Supported |
| **Recording Management** | Full control per room | Standard management |
| **API** | RESTful JSON API | RESTful API |
| **Setup Complexity** | Simple (5 min) | Moderate (10-15 min) |
| **Customization** | Easy (Tailwind CSS) | Requires Ruby/Rails |
| **Community** | doesn't exist lol | Established |
---
## 🚀 Quick Start
### Prerequisites
- Docker & Docker Compose
- BigBlueButton server (with API access)
- SMTP server (optional, for email verification)
### Installation
1. **Clone the repository**
```bash
git clone https://git.scrunkly.cat/Michelle/redlight
cd redlight
```
2. **Configure environment**
```bash
cp .env.example .env
```
Edit `.env` with your settings:
```env
BBB_URL=https://your-bbb-server.com/bigbluebutton/api/
BBB_SECRET=your-bbb-shared-secret
JWT_SECRET=your-secret-key # REQUIRED app won't start without this
APP_URL=https://your-domain.com # Used for CORS and email links
DATABASE_URL=postgres://user:password@postgres:5432/redlight
POSTGRES_USER=redlight
POSTGRES_PASSWORD=redlight
POSTGRES_DB=redlight
# DragonflyDB / Redis (JWT blacklist for logout)
REDIS_URL=redis://dragonfly:6379
# Reverse proxy trust (default: loopback)
# TRUST_PROXY=loopback
# Optional: Email verification
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
# Optional: Federation (cross-instance room invites)
# FEDERATION_DOMAIN=your-domain.com
```
3. **Start the application**
```bash
docker-compose up -d
```
4. **Access the application**
- Open `http://localhost:3001` in your browser
- Default admin: `admin@example.com` / `admin123`
- Change password immediately!
---
## 🛠️ Development
### Local Setup
1. **Install dependencies**
```bash
npm install
```
2. **Start development server**
```bash
npm run dev
```
- Frontend: http://localhost:5173
- Backend: http://localhost:3001
3. **Build for production**
```bash
npm run build
npm run preview
```
### Tech Stack
- **Frontend**: React 18, Tailwind CSS, React Router, Lucide Icons
- **Backend**: Node.js 20, Express, JWT, Bcrypt
- **Database**: SQLite / PostgreSQL with better-sqlite3 / pg
- **Cache**: DragonflyDB / Redis (ioredis) JWT blacklisting
- **Email**: Nodemailer
- **Build**: Vite
---
## 📁 Project Structure
```
redlight/
├── server/ # Node.js/Express backend
│ ├── config/ # Database, Redis, mailer, BBB & federation config
│ ├── middleware/ # JWT authentication & token blacklisting
│ ├── routes/ # API endpoints (auth, rooms, recordings, admin, branding, federation)
│ └── index.js # Server entry point
├── src/ # React frontend
│ ├── components/ # Reusable components
│ ├── contexts/ # React context (Auth, Language, Theme, Branding)
│ ├── i18n/ # Translations (DE, EN)
│ ├── pages/ # Page components
│ ├── services/ # API client
│ ├── themes/ # Tailwind theme config
│ └── main.jsx # Frontend entry point
├── public/ # Static assets
├── uploads/ # User avatars, branding & presentations (runtime)
├── compose.yml # Docker Compose (Redlight + PostgreSQL + DragonflyDB)
├── Dockerfile # Multi-stage container image
└── package.json # Dependencies
```
---
## 🔐 Security
- **JWT Authentication** Secure token-based auth with 7-day expiration and `jti`-based blacklisting via DragonflyDB/Redis
- **Mandatory JWT Secret** Server refuses to start without a `JWT_SECRET` env var
- **HTTPS Ready** Configure behind reverse proxy (nginx, Caddy); trust proxy via `TRUST_PROXY` env
- **Password Hashing** bcryptjs with salt rounds 12, minimum 8-character passwords
- **Email Verification** Optional SMTP-based email verification with resend support
- **CORS Protection** Restricted to `APP_URL` in production, open in development
- **Rate Limiting** Login, register, profile, password, avatar, guest-join, and federation endpoints
- **Input Validation** Email regex, field length limits, ID format checks, hex-color format checks
- **Timing-Safe Comparisons** Access codes and moderator codes compared via `crypto.timingSafeEqual`
- **Upload Safety** Streaming body size limits (avatar 5 MB, presentation 50 MB) abort early without buffering
- **XSS / Injection Prevention** HTML-escaped emails, XML-escaped BBB API parameters, SVG logos served as `attachment`
- **Admin Isolation** Role-based access control with strict admin checks
---
## 📦 API Endpoints
### Authentication
- `POST /api/auth/register` Register new user
- `POST /api/auth/login` Login user
- `POST /api/auth/logout` Logout (blacklists JWT)
- `GET /api/auth/verify-email?token=...` Verify email with token
- `POST /api/auth/resend-verification` Resend verification email
- `GET /api/auth/me` Get current user info
- `PUT /api/auth/profile` Update profile (theme, language, display name)
- `PUT /api/auth/password` Change password
- `POST /api/auth/avatar` Upload avatar image
- `DELETE /api/auth/avatar` Remove avatar image
### Rooms
- `GET /api/rooms` List user's rooms (owned + shared)
- `POST /api/rooms` Create new room
- `GET /api/rooms/:uid` Get room details
- `PUT /api/rooms/:uid` Update room
- `DELETE /api/rooms/:uid` Delete room
- `POST /api/rooms/:uid/start` Start meeting
- `POST /api/rooms/:uid/join` Join meeting as authenticated user
- `POST /api/rooms/:uid/guest-join` Join meeting as guest (rate-limited)
- `POST /api/rooms/:uid/end` End meeting
- `GET /api/rooms/:uid/running` Check if meeting is running
- `GET /api/rooms/:uid/shares` List shared users
- `POST /api/rooms/:uid/shares` Share room with user
- `DELETE /api/rooms/:uid/shares/:userId` Remove share
- `POST /api/rooms/:uid/presentation` Upload default presentation (PDF, PPTX, ODP, images)
- `DELETE /api/rooms/:uid/presentation` Remove presentation
### Recordings
- `GET /api/recordings/:roomUid` List room recordings
- `PUT /api/recordings/:recordingId` Publish/unpublish recording
- `DELETE /api/recordings/:recordingId` Delete recording
### Admin
- `GET /api/admin/users` List all users
- `GET /api/admin/stats` System statistics
- `POST /api/admin/users` Create user (admin)
- `PUT /api/admin/users/:id` Update user
- `DELETE /api/admin/users/:id` Delete user
### Branding
- `GET /api/branding` Get branding settings
- `PUT /api/branding` Update branding (admin only)
- `POST /api/branding/logo` Upload custom logo
- `DELETE /api/branding/logo` Remove custom logo
### Federation
- `GET /.well-known/redlight` Instance discovery (domain, public key)
- `POST /api/federation/invite` Send invitation to remote user
- `POST /api/federation/receive` Receive invitation from remote instance (rate-limited)
- `GET /api/federation/invitations` List received invitations
- `PUT /api/federation/invitations/:id` Accept / decline invitation
- `DELETE /api/federation/invitations/:id` Delete invitation
---
## 🌍 Internationalization (i18n)
Redlight comes with built-in support for multiple languages. Currently supported:
- 🇩🇪 Deutsch (German)
- 🇬🇧 English
### Adding a new language
1. Create `src/i18n/xx.json` (e.g., `fr.json` for French)
2. Copy structure from `de.json` or `en.json`
3. Translate all strings
4. Update `src/i18n/index.js` to include the new language
---
## 🎨 Themes
Redlight includes the following themes:
- 🌙 Dracula
- ❄️ Nord
- 🐱 Catppuccin
- 🌹 Rosé Pine
- 🍂 Gruvbox (Dark, Light)
- 💜 One Dark
- 🌊 Tokyo Night
- And more...
Themes are fully customizable by editing `src/themes/index.js`.
---
## 🐳 Docker Deployment
### Using Docker Compose (Recommended)
```bash
docker-compose up -d
```
Services:
- **redlight** Node.js application
- **postgres** PostgreSQL database
- **dragonfly** DragonflyDB (Redis-compatible) for JWT blacklisting
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `BBB_URL` | Yes | | BigBlueButton API URL |
| `BBB_SECRET` | Yes | | BigBlueButton shared secret |
| `JWT_SECRET` | Yes | | Secret for signing JWTs (server won't start without it) |
| `APP_URL` | Recommended | | Public URL of the app (used for CORS + email links) |
| `DATABASE_URL` | No | SQLite | PostgreSQL connection string |
| `REDIS_URL` | No | `redis://localhost:6379` | DragonflyDB / Redis URL |
| `TRUST_PROXY` | No | `loopback` | Express trust proxy setting (number or string) |
| `SMTP_HOST` | No | | SMTP server for email verification |
| `SMTP_PORT` | No | `587` | SMTP port |
| `SMTP_USER` | No | | SMTP username |
| `SMTP_PASS` | No | | SMTP password |
| `FEDERATION_DOMAIN` | No | | Domain for federation (enables cross-instance invites) |
### Production Deployment
Behind a reverse proxy (nginx example):
```nginx
upstream redlight {
server localhost:3001;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
client_max_body_size 5M;
location / {
proxy_pass http://redlight;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
> **Note:** When running behind a reverse proxy, set `TRUST_PROXY=1` (or the appropriate value) in `.env` so Express reads the correct client IP for rate limiting.
---
## 🌐 Federation
Federation allows users on different Redlight instances to invite each other into rooms.
### Setup
1. Set `FEDERATION_DOMAIN=your-domain.com` in `.env`.
2. On first start, an Ed25519 key pair is generated automatically and stored in `server/config/federation_key.pem`.
3. Other instances discover your public key via `GET /.well-known/redlight`.
### How it works
1. **User A** on `instance-a.com` sends an invite to `userB@instance-b.com`.
2. Redlight looks up `instance-b.com/.well-known/redlight` to discover the federation API.
3. The invite payload is signed with instance A's private key and POSTed to instance B's `/api/federation/receive`.
4. Instance B verifies the Ed25519 signature against instance A's public key.
5. **User B** sees the invitation and can accept or decline. Accepting provides a join link to the remote room.
---
## 🐛 Troubleshooting
### Issue: "ERR_ERL_PERMISSIVE_TRUST_PROXY"
**Solution**: Set `TRUST_PROXY` in `.env`. Use `loopback` (default) or `1` when behind a single reverse proxy.
### Issue: "JWT_SECRET is not set"
**Solution**: The server requires a `JWT_SECRET` environment variable and will refuse to start without one. Add it to your `.env` file.
### Issue: "Email verification not working"
**Solution**: Ensure SMTP is configured in `.env`. If `SMTP_HOST` is not set, email verification is disabled.
### Issue: "BigBlueButton API error"
**Solution**: Verify `BBB_URL` and `BBB_SECRET` are correct. Test the connection with:
```bash
curl "https://your-bbb-server/bigbluebutton/api/getMeetings?checksum=..."
```
### Issue: "Database connection failed"
**Solution**: Check `DATABASE_URL` format. For PostgreSQL: `postgres://user:password@host:5432/redlight`
### Issue: "Theme not applying"
**Solution**: Clear browser cache (Ctrl+Shift+Del) or restart dev server with `npm run dev`.
### Issue: "DragonflyDB connection error"
**Solution**: Ensure DragonflyDB (or Redis) is running and `REDIS_URL` is correct. If unavailable, the app still works — JWT blacklisting degrades gracefully (logout won't revoke tokens immediately).
---
## 📝 License
This project is licensed under the MIT License see [LICENSE](LICENSE) file for details.
---
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit changes (`git commit -m 'Add amazing feature'`)
4. Push to branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request

View File

@@ -4,26 +4,19 @@ services:
restart: unless-stopped
ports:
- "3001:3001"
environment:
DATABASE_URL: postgres://redlight:redlight@postgres:5432/redlight
BBB_URL: https://your-bbb-server.com/bigbluebutton/api/
BBB_SECRET: your-bbb-shared-secret
JWT_SECRET: change-me-to-a-random-secret
ADMIN_EMAIL: admin@example.com
ADMIN_PASSWORD: admin123
env_file: ".env"
volumes:
- uploads:/app/uploads
depends_on:
postgres:
condition: service_healthy
dragonfly:
condition: service_healthy
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: redlight
POSTGRES_PASSWORD: redlight
POSTGRES_DB: redlight
env_file: ".env"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
@@ -32,6 +25,20 @@ services:
timeout: 5s
retries: 5
dragonfly:
image: ghcr.io/dragonflydb/dragonfly:latest
restart: unless-stopped
ulimits:
memlock: -1
volumes:
- dragonflydata:/data
healthcheck:
test: ["CMD", "redis-cli", "-p", "6379", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
uploads:
dragonflydata:

229
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "redlight",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "redlight",
"version": "1.0.0",
"version": "1.2.0",
"dependencies": {
"axios": "^1.7.0",
"bcryptjs": "^2.4.3",
@@ -15,13 +15,19 @@
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.5.1",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0",
"multer": "^2.0.2",
"nodemailer": "^8.0.1",
"pg": "^8.18.0",
"rate-limit-redis": "^4.3.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0",
"uuid": "^13.0.0",
"xml2js": "^0.6.0"
},
"devDependencies": {
@@ -720,6 +726,12 @@
"node": ">=12"
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1333,6 +1345,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -1599,6 +1617,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1754,6 +1789,15 @@
"node": ">=12"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1794,6 +1838,21 @@
"node": ">= 6"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
@@ -1901,7 +1960,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1948,6 +2006,15 @@
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2225,6 +2292,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -2638,6 +2720,30 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ioredis": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz",
"integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -2835,12 +2941,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -3022,6 +3140,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -3034,6 +3164,24 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -3111,6 +3259,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -3627,6 +3784,18 @@
"node": ">= 0.6"
}
},
"node_modules/rate-limit-redis": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.3.1.tgz",
"integrity": "sha512-+a1zU8+D7L8siDK9jb14refQXz60vq427VuiplgnaLk9B2LnvGe/APLTfhwb4uNIL7eWVknh8GnRp/unCj+lMA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"peerDependencies": {
"express-rate-limit": ">= 6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
@@ -3778,6 +3947,27 @@
"node": ">=8.10.0"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -4159,6 +4349,12 @@
"node": ">= 10.x"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4168,6 +4364,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -4469,6 +4673,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -4524,6 +4734,19 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "redlight",
"private": true,
"version": "1.0.1",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
@@ -19,13 +19,19 @@
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.5.1",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0",
"multer": "^2.0.2",
"nodemailer": "^8.0.1",
"pg": "^8.18.0",
"rate-limit-redis": "^4.3.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0",
"uuid": "^13.0.0",
"xml2js": "^0.6.0"
},
"devDependencies": {

Binary file not shown.

Binary file not shown.

View File

@@ -16,18 +16,87 @@ function buildUrl(apiCall, params = {}) {
return `${BBB_URL}${apiCall}?${queryString}`;
}
async function apiCall(apiCallName, params = {}) {
async function apiCall(apiCallName, params = {}, xmlBody = null) {
const url = buildUrl(apiCallName, params);
// Logging: compact key=value style, filter sensitive params
function formatUTC(d) {
const pad = n => String(n).padStart(2, '0');
const Y = d.getUTCFullYear();
const M = pad(d.getUTCMonth() + 1);
const D = pad(d.getUTCDate());
const h = pad(d.getUTCHours());
const m = pad(d.getUTCMinutes());
const s = pad(d.getUTCSeconds());
return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`;
}
const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /auth/i, /api[_-]?key/i];
const isSensitive = key => SENSITIVE_KEYS.some(rx => rx.test(key));
function sanitizeParams(p) {
try {
const out = [];
for (const k of Object.keys(p || {})) {
if (k.toLowerCase() === 'checksum') continue; // never log checksum
if (isSensitive(k)) {
out.push(`${k}=[FILTERED]`);
} else {
let v = p[k];
if (typeof v === 'string' && v.length > 100) v = v.slice(0, 100) + '...[truncated]';
out.push(`${k}=${String(v)}`);
}
}
return out.join('&') || '-';
} catch (e) {
return '-';
}
}
const start = Date.now();
try {
const response = await fetch(url);
const fetchOptions = xmlBody
? { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: xmlBody }
: {};
const response = await fetch(url, fetchOptions);
const duration = Date.now() - start;
const xml = await response.text();
const result = await xml2js.parseStringPromise(xml, {
explicitArray: false,
trim: true,
});
// Compact log: time=... method=GET path=getMeetings format=xml status=200 duration=12.34 bbb_returncode=SUCCESS params=meetingID=123
try {
const tokens = [];
tokens.push(`time=${formatUTC(new Date())}`);
tokens.push(`method=${xmlBody ? 'POST' : 'GET'}`);
// include standard BBB api base path
let apiBasePath = '/bigbluebutton/api';
try {
const u = new URL(BBB_URL);
apiBasePath = (u.pathname || '/bigbluebutton/api').replace(/\/$/, '');
} catch (e) {
// keep default
}
// ensure single slash separation
const fullPath = `${apiBasePath}/${apiCallName}`.replace(/\/\/+/, '/');
tokens.push(`path=${fullPath}`);
tokens.push(`format=xml`);
tokens.push(`status=${response.status}`);
tokens.push(`duration=${(duration).toFixed(2)}`);
const returnCode = result && result.response && result.response.returncode ? result.response.returncode : '-';
tokens.push(`returncode=${returnCode}`);
const safeParams = sanitizeParams(params);
tokens.push(`params=${safeParams}`);
console.info(tokens.join(' '));
} catch (e) {
// ignore logging errors
}
return result.response;
} catch (error) {
console.error(`BBB API error (${apiCallName}):`, error.message);
const duration = Date.now() - start;
console.error(`BBB API error (${apiCallName}) status=error duration=${(duration).toFixed(2)} err=${error.message}`);
throw error;
}
}
@@ -39,14 +108,25 @@ function getRoomPasswords(uid) {
return { moderatorPW: modPw, attendeePW: attPw };
}
export async function createMeeting(room) {
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null) {
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
// Build welcome message with guest invite link
let welcome = room.welcome_message || t('defaultWelcome');
if (logoutURL) {
const guestLink = `${logoutURL}/join/${room.uid}`;
welcome += `<br><br>To invite other participants, share this link:<br><a href="${guestLink}">${guestLink}</a>`;
if (room.access_code) {
welcome += `<br>Access Code: <b>${room.access_code}</b>`;
}
}
const params = {
meetingID: room.uid,
name: room.name,
attendeePW,
moderatorPW,
welcome: room.welcome_message || 'Willkommen!',
welcome,
record: room.record_meeting ? 'true' : 'false',
autoStartRecording: 'false',
allowStartStopRecording: 'true',
@@ -54,16 +134,34 @@ export async function createMeeting(room) {
'meta_bbb-origin': 'Redlight',
'meta_bbb-origin-server-name': 'Redlight',
};
if (logoutURL) {
params.logoutURL = logoutURL;
}
if (loginURL) {
params.loginURL = loginURL;
}
if (room.max_participants > 0) {
params.maxParticipants = room.max_participants.toString();
}
if (room.access_code) {
params.lockSettingsLockOnJoin = 'true';
}
return apiCall('create', params);
// Build optional presentation XML body escape URL to prevent XML injection
let xmlBody = null;
if (presentationUrl) {
const safeUrl = presentationUrl
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
xmlBody = `<modules><module name="presentation"><document url="${safeUrl}" /></module></modules>`;
}
return apiCall('create', params, xmlBody);
}
export async function joinMeeting(uid, name, isModerator = false) {
export async function joinMeeting(uid, name, isModerator = false, avatarURL = null) {
const { moderatorPW, attendeePW } = getRoomPasswords(uid);
const params = {
meetingID: uid,
@@ -71,6 +169,9 @@ export async function joinMeeting(uid, name, isModerator = false) {
password: isModerator ? moderatorPW : attendeePW,
redirect: 'true',
};
if (avatarURL) {
params.avatarURL = avatarURL;
}
return buildUrl('join', params);
}
@@ -103,6 +204,17 @@ export async function getRecordings(meetingID) {
return Array.isArray(recordings) ? recordings : [recordings];
}
export async function getRecordingByRecordId(recordID) {
const result = await apiCall('getRecordings', { recordID });
if (result.returncode !== 'SUCCESS' || !result.recordings) {
return null;
}
const recordings = result.recordings.recording;
if (!recordings) return null;
const arr = Array.isArray(recordings) ? recordings : [recordings];
return arr[0] || null;
}
export async function deleteRecording(recordID) {
return apiCall('deleteRecordings', { recordID });
}

View File

@@ -127,6 +127,7 @@ export async function initDatabase() {
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
display_name TEXT DEFAULT '',
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
@@ -134,6 +135,10 @@ export async function initDatabase() {
theme TEXT DEFAULT 'dark',
avatar_color TEXT DEFAULT '#6366f1',
avatar_image TEXT DEFAULT NULL,
email_verified INTEGER DEFAULT 0,
verification_token TEXT,
verification_token_expires TIMESTAMP,
verification_resend_at TIMESTAMP DEFAULT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
@@ -153,19 +158,65 @@ export async function initDatabase() {
record_meeting INTEGER DEFAULT 1,
guest_access INTEGER DEFAULT 0,
moderator_code TEXT,
presentation_file TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS room_shares (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS federation_invitations (
id SERIAL PRIMARY KEY,
invite_id TEXT UNIQUE NOT NULL,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
room_name TEXT NOT NULL,
message TEXT,
join_url TEXT NOT NULL,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
CREATE TABLE IF NOT EXISTS federated_rooms (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invite_id TEXT UNIQUE NOT NULL,
room_name TEXT NOT NULL,
from_user TEXT NOT NULL,
join_url TEXT NOT NULL,
meet_id TEXT,
max_participants INTEGER DEFAULT 0,
allow_recording INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
display_name TEXT DEFAULT '',
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
@@ -173,6 +224,10 @@ export async function initDatabase() {
theme TEXT DEFAULT 'dark',
avatar_color TEXT DEFAULT '#6366f1',
avatar_image TEXT DEFAULT NULL,
email_verified INTEGER DEFAULT 0,
verification_token TEXT,
verification_token_expires DATETIME,
verification_resend_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -192,14 +247,64 @@ export async function initDatabase() {
record_meeting INTEGER DEFAULT 1,
guest_access INTEGER DEFAULT 0,
moderator_code TEXT,
presentation_file TEXT DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS room_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(room_id, user_id),
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS federation_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invite_id TEXT UNIQUE NOT NULL,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL,
room_name TEXT NOT NULL,
message TEXT,
join_url TEXT NOT NULL,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_fed_inv_to_user ON federation_invitations(to_user_id);
CREATE INDEX IF NOT EXISTS idx_fed_inv_invite_id ON federation_invitations(invite_id);
CREATE TABLE IF NOT EXISTS federated_rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
invite_id TEXT UNIQUE NOT NULL,
room_name TEXT NOT NULL,
from_user TEXT NOT NULL,
join_url TEXT NOT NULL,
meet_id TEXT,
max_participants INTEGER DEFAULT 0,
allow_recording INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_fed_rooms_user_id ON federated_rooms(user_id);
`);
}
@@ -213,6 +318,54 @@ export async function initDatabase() {
if (!(await db.columnExists('rooms', 'moderator_code'))) {
await db.exec('ALTER TABLE rooms ADD COLUMN moderator_code TEXT');
}
if (!(await db.columnExists('users', 'email_verified'))) {
await db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0');
}
if (!(await db.columnExists('users', 'verification_token'))) {
await db.exec('ALTER TABLE users ADD COLUMN verification_token TEXT');
}
if (!(await db.columnExists('users', 'verification_token_expires'))) {
if (isPostgres) {
await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires TIMESTAMP');
} else {
await db.exec('ALTER TABLE users ADD COLUMN verification_token_expires DATETIME');
}
}
if (!(await db.columnExists('federated_rooms', 'meet_id'))) {
await db.exec('ALTER TABLE federated_rooms ADD COLUMN meet_id TEXT');
}
if (!(await db.columnExists('federated_rooms', 'max_participants'))) {
await db.exec('ALTER TABLE federated_rooms ADD COLUMN max_participants INTEGER DEFAULT 0');
}
if (!(await db.columnExists('federated_rooms', 'allow_recording'))) {
await db.exec('ALTER TABLE federated_rooms ADD COLUMN allow_recording INTEGER DEFAULT 1');
}
if (!(await db.columnExists('federation_invitations', 'room_uid'))) {
await db.exec('ALTER TABLE federation_invitations ADD COLUMN room_uid TEXT');
}
if (!(await db.columnExists('federation_invitations', 'max_participants'))) {
await db.exec('ALTER TABLE federation_invitations ADD COLUMN max_participants INTEGER DEFAULT 0');
}
if (!(await db.columnExists('federation_invitations', 'allow_recording'))) {
await db.exec('ALTER TABLE federation_invitations ADD COLUMN allow_recording INTEGER DEFAULT 1');
}
if (!(await db.columnExists('users', 'display_name'))) {
await db.exec("ALTER TABLE users ADD COLUMN display_name TEXT DEFAULT ''");
await db.exec("UPDATE users SET display_name = name WHERE display_name = ''");
}
if (!(await db.columnExists('rooms', 'presentation_file'))) {
await db.exec('ALTER TABLE rooms ADD COLUMN presentation_file TEXT DEFAULT NULL');
}
if (!(await db.columnExists('rooms', 'presentation_name'))) {
await db.exec('ALTER TABLE rooms ADD COLUMN presentation_name TEXT DEFAULT NULL');
}
if (!(await db.columnExists('users', 'verification_resend_at'))) {
if (isPostgres) {
await db.exec('ALTER TABLE users ADD COLUMN verification_resend_at TIMESTAMP DEFAULT NULL');
} else {
await db.exec('ALTER TABLE users ADD COLUMN verification_resend_at DATETIME DEFAULT NULL');
}
}
// ── Default admin ───────────────────────────────────────────────────────
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
@@ -222,7 +375,7 @@ export async function initDatabase() {
if (!existingAdmin) {
const hash = bcrypt.hashSync(adminPassword, 12);
await db.run(
'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)',
'INSERT INTO users (name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, 1)',
['Administrator', adminEmail, hash, 'admin']
);
console.log(`✅ Default admin created: ${adminEmail}`);

156
server/config/federation.js Normal file
View File

@@ -0,0 +1,156 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const FEDERATION_DOMAIN = process.env.FEDERATION_DOMAIN || '';
let privateKeyPem = process.env.FEDERATION_PRIVATE_KEY || '';
let publicKeyPem = '';
// Load or generate Ed25519 keys
if (FEDERATION_DOMAIN) {
const keyPath = path.join(__dirname, 'federation_key.pem');
if (!privateKeyPem && fs.existsSync(keyPath)) {
privateKeyPem = fs.readFileSync(keyPath, 'utf8');
}
if (!privateKeyPem) {
console.log('Generating new Ed25519 federation key pair...');
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
privateKeyPem = privateKey;
fs.writeFileSync(keyPath, privateKeyPem, 'utf8');
console.log(`Saved new federation private key to ${keyPath}`);
}
// Derive public key from the loaded private key
const currentPrivateKey = crypto.createPrivateKey(privateKeyPem);
publicKeyPem = crypto.createPublicKey(currentPrivateKey).export({ type: 'spki', format: 'pem' });
}
// Instance discovery cache (domain → { baseUrl, publicKey, cachedAt })
const discoveryCache = new Map();
const DISCOVERY_TTL_MS = 5 * 60 * 1000; // 5 minutes
/**
* Get this instance's federation domain.
*/
export function getFederationDomain() {
return FEDERATION_DOMAIN;
}
/**
* Get this instance's Ed25519 public key (PEM format).
*/
export function getPublicKey() {
return publicKeyPem;
}
/**
* Check if federation is configured on this instance.
*/
export function isFederationEnabled() {
return !!(FEDERATION_DOMAIN && privateKeyPem);
}
/**
* Ed25519 sign a JSON payload.
* @param {object} payload
* @returns {string} base64 signature
*/
export function signPayload(payload) {
if (!privateKeyPem) throw new Error("Federation private key not available");
const data = Buffer.from(JSON.stringify(payload));
return crypto.sign(null, data, privateKeyPem).toString('base64');
}
/**
* Verify an Ed25519 signature against a JSON payload using a remote public key.
* @param {object} payload
* @param {string} signature base64 signature
* @param {string} remotePublicKeyPem
* @returns {boolean}
*/
export function verifyPayload(payload, signature, remotePublicKeyPem) {
if (!remotePublicKeyPem || !signature) return false;
try {
const data = Buffer.from(JSON.stringify(payload));
return crypto.verify(null, data, remotePublicKeyPem, Buffer.from(signature, 'base64'));
} catch (e) {
console.error('Signature verification error:', e.message);
return false;
}
}
/**
* Discover a remote Redlight instance's federation API base URL.
* Fetches https://{domain}/.well-known/redlight and caches the result.
* @param {string} domain
* @returns {Promise<{ baseUrl: string, publicKey: string }>}
*/
export async function discoverInstance(domain) {
const cached = discoveryCache.get(domain);
if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) {
return cached;
}
const wellKnownUrl = `https://${domain}/.well-known/redlight`;
const TIMEOUT_MS = 10_000; // 10 seconds
try {
let response;
try {
response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch (e) {
if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) {
response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} else throw e;
}
if (!response.ok) {
throw new Error(`Discovery failed: HTTP ${response.status}`);
}
const data = await response.json();
if (!data.public_key) {
throw new Error(`Remote instance at ${domain} did not provide a public key`);
}
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
const result = {
baseUrl: baseUrl.replace('https://localhost', 'http://localhost'),
publicKey: data.public_key,
cachedAt: Date.now(),
};
discoveryCache.set(domain, result);
return result;
} catch (error) {
console.error(`Federation discovery failed for ${domain}:`, error.message);
throw new Error(`Could not discover Redlight instance at ${domain}: ${error.message}`);
}
}
/**
* Parse a federated address like "username@domain.com".
* @param {string} address
* @returns {{ username: string, domain: string | null }}
*/
export function parseAddress(address) {
if (!address) return { username: address, domain: null };
// Accept both @user@domain (Mastodon-style) and user@domain
const normalized = address.startsWith('@') ? address.slice(1) : address;
if (!normalized.includes('@')) {
return { username: normalized, domain: null };
}
const atIndex = normalized.lastIndexOf('@');
return {
username: normalized.substring(0, atIndex),
domain: normalized.substring(atIndex + 1),
};
}

140
server/config/mailer.js Normal file
View File

@@ -0,0 +1,140 @@
import nodemailer from 'nodemailer';
let transporter;
// Escape HTML special characters to prevent injection in email bodies
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function initMailer() {
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || '587', 10);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
console.warn('⚠️ SMTP not configured email verification disabled');
return false;
}
transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
connectionTimeout: 10_000, // 10 s to establish TCP connection
greetingTimeout: 10_000, // 10 s to receive SMTP greeting
socketTimeout: 15_000, // 15 s of inactivity before abort
});
console.log('✅ SMTP mailer configured');
return true;
}
export function isMailerConfigured() {
return !!transporter;
}
/**
* Send the verification email with a clickable link.
* @param {string} to recipient email
* @param {string} name user's display name
* @param {string} verifyUrl full verification URL
* @param {string} appName branding app name (default "Redlight")
*/
// S3: sanitize name for use in email From header (strip quotes, newlines, control chars)
function sanitizeHeaderValue(str) {
return String(str).replace(/["\\\r\n\x00-\x1f]/g, '').trim().slice(0, 100);
}
export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redlight') {
if (!transporter) {
throw new Error('SMTP not configured');
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeAppName = escapeHtml(appName);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} Verify your email`,
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
<p>Please verify your email address by clicking the button below:</p>
<p style="text-align:center;margin:28px 0;">
<a href="${verifyUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
Verify Email
</a>
</p>
<p style="font-size:13px;color:#7f849c;">
Or copy this link in your browser:<br/>
<a href="${verifyUrl}" style="color:#89b4fa;word-break:break-all;">${escapeHtml(verifyUrl)}</a>
</p>
<p style="font-size:13px;color:#7f849c;">This link is valid for 24 hours.</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">If you didn't register, please ignore this email.</p>
</div>
`,
text: `Hey ${name},\n\nPlease verify your email: ${verifyUrl}\n\nThis link is valid for 24 hours.\n\n ${appName}`,
});
}
/**
* Send a federation meeting invitation email.
* @param {string} to recipient email
* @param {string} name recipient display name
* @param {string} fromUser sender federated address (user@domain)
* @param {string} roomName name of the invited room
* @param {string} message optional personal message
* @param {string} inboxUrl URL to the federation inbox
* @param {string} appName branding app name (default "Redlight")
*/
export async function sendFederationInviteEmail(to, name, fromUser, roomName, message, inboxUrl, appName = 'Redlight') {
if (!transporter) return; // silently skip if SMTP not configured
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeFromUser = escapeHtml(fromUser);
const safeRoomName = escapeHtml(roomName);
const safeMessage = message ? escapeHtml(message) : null;
const safeAppName = escapeHtml(appName);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
to,
subject: `${headerAppName} Meeting invitation from ${sanitizeHeaderValue(fromUser)}`,
html: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
<p>You have received a meeting invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 8px 0;font-size:13px;color:#7f849c;">Room:</p>
<p style="margin:0;font-size:16px;font-weight:bold;color:#cdd6f4;">${safeRoomName}</p>
${safeMessage ? `<p style="margin:12px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeMessage}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
<a href="${inboxUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
View Invitation
</a>
</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p>
</div>
`,
text: `Hey ${name},\n\nYou have received a meeting invitation from ${fromUser}.\nRoom: ${roomName}${message ? `\nMessage: "${message}"` : ''}\n\nView invitation: ${inboxUrl}\n\n ${appName}`,
});
}

24
server/config/redis.js Normal file
View File

@@ -0,0 +1,24 @@
import Redis from 'ioredis';
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const redis = new Redis(REDIS_URL, {
maxRetriesPerRequest: null,
retryStrategy: (times) => {
if (times > 3) return null; // stop retrying after 3 attempts
return Math.min(times * 200, 1000);
},
});
redis.on('error', (err) => {
// Suppress ECONNREFUSED noise after initial failure — only warn
if (err.code !== 'ECONNREFUSED') {
console.warn('⚠️ DragonflyDB error:', err.message);
}
});
redis.on('connect', () => {
console.log('🐉 DragonflyDB connected');
});
export default redis;

View File

@@ -3,11 +3,15 @@ import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import requestResponseLogger from './middleware/logging.js';
import { initDatabase } from './config/database.js';
import { initMailer } from './config/mailer.js';
import authRoutes from './routes/auth.js';
import roomRoutes from './routes/rooms.js';
import recordingRoutes from './routes/recordings.js';
import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -15,19 +19,39 @@ const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
// Trust proxy configurable via TRUST_PROXY env var (default: 1 = one local reverse proxy)
// Use a number to trust that many hops, or a string like 'loopback' / an IP/CIDR.
const rawTrustProxy = process.env.TRUST_PROXY ?? 'loopback';
const trustProxy = /^\d+$/.test(rawTrustProxy) ? parseInt(rawTrustProxy, 10) : rawTrustProxy;
app.set('trust proxy', trustProxy);
// Middleware
app.use(cors());
// M10: restrict CORS in production; allow all in development
const corsOptions = process.env.APP_URL
? { origin: process.env.APP_URL, credentials: true }
: {};
app.use(cors(corsOptions));
app.use(express.json());
// Request/Response logging (filters sensitive fields)
app.use(requestResponseLogger);
// Initialize database & start server
async function start() {
await initDatabase();
initMailer();
// Serve uploaded files (avatars, presentations)
const uploadsPath = path.join(__dirname, '..', 'uploads');
app.use('/uploads', express.static(uploadsPath));
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/rooms', roomRoutes);
app.use('/api/recordings', recordingRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/branding', brandingRoutes);
app.use('/api/federation', federationRoutes);
app.get('/.well-known/redlight', wellKnownHandler);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {

View File

@@ -1,37 +1,58 @@
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import redis from '../config/redis.js';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-me';
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set. ');
process.exit(1);
}
const JWT_SECRET = process.env.JWT_SECRET;
export async function authenticateToken(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Authentifizierung erforderlich' });
return res.status(401).json({ error: 'Authentication required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Check JWT blacklist in DragonflyDB (revoked tokens via logout)
if (decoded.jti) {
try {
const revoked = await redis.get(`blacklist:${decoded.jti}`);
if (revoked) {
return res.status(401).json({ error: 'Token has been revoked' });
}
} catch (redisErr) {
// Graceful degradation: if Redis is unavailable, allow the request
console.warn('Redis blacklist check skipped:', redisErr.message);
}
}
const db = getDb();
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [decoded.userId]);
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [decoded.userId]);
if (!user) {
return res.status(401).json({ error: 'Benutzer nicht gefunden' });
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (err) {
return res.status(403).json({ error: 'Ungültiges Token' });
return res.status(403).json({ error: 'Invalid token' });
}
}
export function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Administratorrechte erforderlich' });
return res.status(403).json({ error: 'Admin rights required' });
}
next();
}
export function generateToken(userId) {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
const jti = uuidv4();
return jwt.sign({ userId, jti }, JWT_SECRET, { expiresIn: '7d' });
}

View File

@@ -0,0 +1,118 @@
import util from 'util';
const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /auth/i, /api[_-]?key/i];
const MAX_LOG_BODY_LENGTH = 200000; // 200 KB
function isSensitiveKey(key) {
return SENSITIVE_KEYS.some(rx => rx.test(key));
}
function filterValue(key, value, depth = 0) {
if (depth > 5) return '[MAX_DEPTH]';
if (key && isSensitiveKey(key)) return '[FILTERED]';
if (value === null || value === undefined) return value;
if (typeof value === 'string') {
if (value.length > MAX_LOG_BODY_LENGTH) return '[TOO_LARGE]';
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (Array.isArray(value)) return value.map(v => filterValue(null, v, depth + 1));
if (typeof value === 'object') {
const out = {};
for (const k of Object.keys(value)) {
out[k] = filterValue(k, value[k], depth + 1);
}
return out;
}
return typeof value;
}
function filterHeaders(headers) {
const out = {};
for (const k of Object.keys(headers || {})) {
if (/^authorization$/i.test(k) || /^cookie$/i.test(k)) {
out[k] = '[FILTERED]';
continue;
}
if (isSensitiveKey(k)) {
out[k] = '[FILTERED]';
continue;
}
out[k] = headers[k];
}
return out;
}
function formatUTC(d) {
const pad = n => String(n).padStart(2, '0');
const Y = d.getUTCFullYear();
const M = pad(d.getUTCMonth() + 1);
const D = pad(d.getUTCDate());
const h = pad(d.getUTCHours());
const m = pad(d.getUTCMinutes());
const s = pad(d.getUTCSeconds());
return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`;
}
export default function requestResponseLogger(req, res, next) {
try {
const start = Date.now();
const { method, originalUrl } = req;
const ip = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const reqHeaders = filterHeaders(req.headers);
let reqBody = '[not-logged]';
const contentType = (req.headers['content-type'] || '').toLowerCase();
if (contentType.includes('multipart/form-data')) {
reqBody = '[multipart/form-data]';
} else if (req.body) {
try {
reqBody = filterValue(null, req.body);
} catch (e) {
reqBody = '[unserializable]';
}
}
// Capture response body by wrapping res.send
const oldSend = res.send.bind(res);
let responseBody = undefined;
res.send = function sendOverWrite(body) {
responseBody = body;
return oldSend(body);
};
res.on('finish', () => {
try {
const duration = Date.now() - start; // ms
const resContentType = (res.getHeader && (res.getHeader('content-type') || '')).toString().toLowerCase();
// Compact key=value log (no bodies, sensitive data filtered)
const tokens = [];
tokens.push(`time=${formatUTC(new Date())}`);
tokens.push(`method=${method}`);
tokens.push(`path=${originalUrl.replace(/\s/g, '%20')}`);
const fmt = resContentType.includes('json') ? 'json' : (resContentType.includes('html') ? 'html' : 'other');
tokens.push(`format=${fmt}`);
tokens.push(`status=${res.statusCode}`);
tokens.push(`duration=${(duration).toFixed(2)}`);
// Optional: content-length if available
try {
const cl = res.getHeader && (res.getHeader('content-length') || res.getHeader('Content-Length'));
if (cl) tokens.push(`length=${String(cl)}`);
} catch (e) {
// ignore
}
console.info(tokens.join(' '));
} catch (e) {
console.error('RequestLogger error:', e);
}
});
} catch (e) {
console.error('RequestLogger setup failure:', e);
}
return next();
}

View File

@@ -3,19 +3,36 @@ import bcrypt from 'bcryptjs';
import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
const router = Router();
// POST /api/admin/users - Create user (admin)
router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
try {
const { name, email, password, role } = req.body;
const { name, display_name, email, password, role } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: 'Alle Felder sind erforderlich' });
if (!name || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
// L4: display_name length limit
if (display_name.length > 100) {
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
}
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
}
// M9: email format validation
if (!EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'Invalid email address' });
}
const validRole = ['user', 'admin'].includes(role) ? role : 'user';
@@ -23,20 +40,25 @@ router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) {
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
return res.status(409).json({ error: 'Email is already in use' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [name]);
if (existingUsername) {
return res.status(409).json({ error: 'Username is already taken' });
}
const hash = bcrypt.hashSync(password, 12);
const result = await db.run(
'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)',
[name, email.toLowerCase(), hash, validRole]
'INSERT INTO users (name, display_name, email, password_hash, role, email_verified) VALUES (?, ?, ?, ?, ?, 1)',
[name, display_name, email.toLowerCase(), hash, validRole]
);
const user = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]);
const user = await db.get('SELECT id, name, display_name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ user });
} catch (err) {
console.error('Create user error:', err);
res.status(500).json({ error: 'Benutzer konnte nicht erstellt werden' });
res.status(500).json({ error: 'User could not be created' });
}
});
@@ -45,7 +67,7 @@ router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
try {
const db = getDb();
const users = await db.all(`
SELECT id, name, email, role, language, theme, avatar_color, avatar_image, created_at,
SELECT id, name, display_name, email, role, language, theme, avatar_color, avatar_image, created_at,
(SELECT COUNT(*) FROM rooms WHERE rooms.user_id = users.id) as room_count
FROM users
ORDER BY created_at DESC
@@ -54,7 +76,7 @@ router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
res.json({ users });
} catch (err) {
console.error('List users error:', err);
res.status(500).json({ error: 'Benutzer konnten nicht geladen werden' });
res.status(500).json({ error: 'Users could not be loaded' });
}
});
@@ -63,7 +85,7 @@ router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res)
try {
const { role } = req.body;
if (!['user', 'admin'].includes(role)) {
return res.status(400).json({ error: 'Ungültige Rolle' });
return res.status(400).json({ error: 'Invalid role' });
}
const db = getDb();
@@ -73,17 +95,22 @@ router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res)
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
const currentUser = await db.get('SELECT role FROM users WHERE id = ?', [req.params.id]);
if (currentUser?.role === 'admin' && adminCount.count <= 1) {
return res.status(400).json({ error: 'Der letzte Admin kann nicht herabgestuft werden' });
return res.status(400).json({ error: 'The last admin cannot be demoted' });
}
}
await db.run('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [role, req.params.id]);
const updated = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [req.params.id]);
// S7: verify user actually exists
if (!updated) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ user: updated });
} catch (err) {
console.error('Update role error:', err);
res.status(500).json({ error: 'Rolle konnte nicht aktualisiert werden' });
res.status(500).json({ error: 'Role could not be updated' });
}
});
@@ -93,27 +120,27 @@ router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) =>
const db = getDb();
if (parseInt(req.params.id) === req.user.id) {
return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' });
return res.status(400).json({ error: 'You cannot delete yourself' });
}
const user = await db.get('SELECT id, role FROM users WHERE id = ?', [req.params.id]);
if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
return res.status(404).json({ error: 'User not found' });
}
// Check if it's the last admin
if (user.role === 'admin') {
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
if (adminCount.count <= 1) {
return res.status(400).json({ error: 'Der letzte Admin kann nicht gelöscht werden' });
return res.status(400).json({ error: 'The last admin cannot be deleted' });
}
}
await db.run('DELETE FROM users WHERE id = ?', [req.params.id]);
res.json({ message: 'Benutzer gelöscht' });
res.json({ message: 'User deleted' });
} catch (err) {
console.error('Delete user error:', err);
res.status(500).json({ error: 'Benutzer konnte nicht gelöscht werden' });
res.status(500).json({ error: 'User could not be deleted' });
}
});
@@ -121,18 +148,18 @@ router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) =>
router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, res) => {
try {
const { newPassword } = req.body;
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters long' });
}
const db = getDb();
const hash = bcrypt.hashSync(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]);
res.json({ message: 'Passwort zurückgesetzt' });
res.json({ message: 'Password reset' });
} catch (err) {
console.error('Reset password error:', err);
res.status(500).json({ error: 'Passwort konnte nicht zurückgesetzt werden' });
res.status(500).json({ error: 'Password could not be reset' });
}
});

View File

@@ -1,10 +1,101 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import { getDb } from '../config/database.js';
import redis from '../config/redis.js';
import { authenticateToken, generateToken } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set.');
process.exit(1);
}
const JWT_SECRET = process.env.JWT_SECRET;
// ── Rate Limiting ────────────────────────────────────────────────────────────
function makeRedisStore(prefix) {
try {
return new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix,
});
} catch {
return undefined; // falls back to in-memory if Redis unavailable
}
}
// ── Validation helpers ─────────────────────────────────────────────────────
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/;
// Simple format check for theme/language IDs (actual validation happens on the frontend)
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
// Allowlist for CSS color values only permits hsl(), hex (#rgb/#rrggbb) and plain names
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
const MIN_PASSWORD_LENGTH = 8;
// ── Rate Limiters ────────────────────────────────────────────────────────────
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Please try again in 15 minutes.' },
store: makeRedisStore('rl:login:'),
});
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many registration attempts. Please try again later.' },
store: makeRedisStore('rl:register:'),
});
const profileLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many profile update attempts. Please try again later.' },
store: makeRedisStore('rl:profile:'),
});
const passwordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many password change attempts. Please try again later.' },
store: makeRedisStore('rl:password:'),
});
const avatarLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many avatar upload attempts. Please try again later.' },
store: makeRedisStore('rl:avatar:'),
});
// S1: rate limit resend-verification to prevent SMTP abuse
const resendVerificationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
store: makeRedisStore('rl:resend:'),
});
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -18,54 +109,213 @@ if (!fs.existsSync(uploadsDir)) {
const router = Router();
// POST /api/auth/register
router.post('/register', async (req, res) => {
router.post('/register', registerLimiter, async (req, res) => {
try {
const { name, email, password } = req.body;
const { username, display_name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ error: 'Alle Felder sind erforderlich' });
if (!username || !display_name || !email || !password) {
return res.status(400).json({ error: 'All fields are required' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
// L3: display_name length limit (consistent with profile update)
if (display_name.length > 100) {
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
}
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
// M1: email format
if (!EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'Invalid email address' });
}
// M4: minimum password length
if (password.length < MIN_PASSWORD_LENGTH) {
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
}
const db = getDb();
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
if (existing) {
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
return res.status(409).json({ error: 'Email is already in use' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [username]);
if (existingUsername) {
return res.status(409).json({ error: 'Username is already taken' });
}
const hash = bcrypt.hashSync(password, 12);
// If SMTP is configured, require email verification
if (isMailerConfigured()) {
const verificationToken = uuidv4();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
await db.run(
'INSERT INTO users (name, display_name, email, password_hash, email_verified, verification_token, verification_token_expires) VALUES (?, ?, ?, ?, 0, ?, ?)',
[username, display_name, email.toLowerCase(), hash, verificationToken, expires]
);
// Build verification URL
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
// Load app name from branding settings
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
}
try {
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName);
} catch (mailErr) {
console.error('Verification mail failed:', mailErr.message);
// Account is created but email failed — user can resend from login page
return res.status(201).json({ needsVerification: true, emailFailed: true, message: 'Account created but verification email could not be sent. Please try resending.' });
}
return res.status(201).json({ needsVerification: true, message: 'Verification email has been sent' });
}
// No SMTP configured register and login immediately (legacy behaviour)
const result = await db.run(
'INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)',
[name, email.toLowerCase(), hash]
'INSERT INTO users (name, display_name, email, password_hash, email_verified) VALUES (?, ?, ?, ?, 1)',
[username, display_name, email.toLowerCase(), hash]
);
const token = generateToken(result.lastInsertRowid);
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [result.lastInsertRowid]);
const user = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ token, user });
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Registrierung fehlgeschlagen' });
res.status(500).json({ error: 'Registration failed' });
}
});
// GET /api/auth/verify-email?token=...
router.get('/verify-email', async (req, res) => {
try {
const { token } = req.query;
if (!token) {
return res.status(400).json({ error: 'Token is missing' });
}
const db = getDb();
const user = await db.get(
'SELECT id, verification_token_expires FROM users WHERE verification_token = ? AND email_verified = 0',
[token]
);
if (!user) {
return res.status(400).json({ error: 'Invalid or already used token' });
}
if (new Date(user.verification_token_expires) < new Date()) {
return res.status(400).json({ error: 'Token has expired. Please register again.' });
}
await db.run(
'UPDATE users SET email_verified = 1, verification_token = NULL, verification_token_expires = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[user.id]
);
res.json({ verified: true, message: 'Email verified successfully' });
} catch (err) {
console.error('Verify email error:', err);
res.status(500).json({ error: 'Verification failed' });
}
});
// POST /api/auth/resend-verification
router.post('/resend-verification', resendVerificationLimiter, async (req, res) => {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({ error: 'Email is required' });
}
if (!isMailerConfigured()) {
return res.status(400).json({ error: 'SMTP is not configured' });
}
const db = getDb();
const user = await db.get('SELECT id, name, display_name, email_verified, verification_resend_at FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || user.email_verified) {
// Don't reveal whether account exists
return res.json({ message: 'If an account exists, a new email has been sent.' });
}
// Server-side 60s rate limit
if (user.verification_resend_at) {
const secondsAgo = (Date.now() - new Date(user.verification_resend_at).getTime()) / 1000;
if (secondsAgo < 60) {
const waitSeconds = Math.ceil(60 - secondsAgo);
return res.status(429).json({ error: `Please wait ${waitSeconds} seconds before requesting another email.`, waitSeconds });
}
}
const verificationToken = uuidv4();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
const now = new Date().toISOString();
await db.run(
'UPDATE users SET verification_token = ?, verification_token_expires = ?, verification_resend_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[verificationToken, expires, now, user.id]
);
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
}
try {
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName);
} catch (mailErr) {
console.error('Resend verification mail failed:', mailErr.message);
return res.status(502).json({ error: 'Email could not be sent. Please check your SMTP configuration.' });
}
res.json({ message: 'If an account exists, a new email has been sent.' });
} catch (err) {
console.error('Resend verification error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
router.post('/login', loginLimiter, async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
return res.status(400).json({ error: 'Email and password are required' });
}
// M1: basic email format check invalid format can never match a real account
if (!EMAIL_RE.test(email)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const db = getDb();
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
return res.status(401).json({ error: 'Invalid credentials' });
}
if (!user.email_verified && isMailerConfigured()) {
return res.status(403).json({ error: 'Email address not yet verified. Please check your inbox.', needsVerification: true });
}
const token = generateToken(user.id);
@@ -74,7 +324,32 @@ router.post('/login', async (req, res) => {
res.json({ token, user: safeUser });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Anmeldung fehlgeschlagen' });
res.status(500).json({ error: 'Login failed' });
}
});
// POST /api/auth/logout revoke JWT via DragonflyDB blacklist
router.post('/logout', authenticateToken, async (req, res) => {
try {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
const decoded = jwt.decode(token);
if (decoded?.jti && decoded?.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
try {
await redis.setex(`blacklist:${decoded.jti}`, ttl, '1');
} catch (redisErr) {
console.warn('Redis blacklist write failed:', redisErr.message);
}
}
}
res.json({ message: 'Logged out successfully' });
} catch (err) {
console.error('Logout error:', err);
res.status(500).json({ error: 'Logout failed' });
}
});
@@ -84,81 +359,140 @@ router.get('/me', authenticateToken, (req, res) => {
});
// PUT /api/auth/profile
router.put('/profile', authenticateToken, async (req, res) => {
router.put('/profile', authenticateToken, profileLimiter, async (req, res) => {
try {
const { name, email, theme, language, avatar_color } = req.body;
const { name, display_name, email, theme, language, avatar_color } = req.body;
const db = getDb();
// M1: validate new email format
if (email && !EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'Invalid email address' });
}
if (email && email !== req.user.email) {
const existing = await db.get('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]);
if (existing) {
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
return res.status(409).json({ error: 'Email is already in use' });
}
}
// M2: display_name length limit
if (display_name !== undefined && display_name !== null && display_name.length > 100) {
return res.status(400).json({ error: 'Display name must not exceed 100 characters' });
}
// Theme and language: basic format validation (frontend handles actual ID matching)
if (theme !== undefined && theme !== null && (typeof theme !== 'string' || !SAFE_ID_RE.test(theme))) {
return res.status(400).json({ error: 'Invalid theme' });
}
if (language !== undefined && language !== null && (typeof language !== 'string' || !SAFE_ID_RE.test(language))) {
return res.status(400).json({ error: 'Invalid language' });
}
// L5: validate avatar_color format/length
if (avatar_color !== undefined && avatar_color !== null) {
if (typeof avatar_color !== 'string' || !SAFE_COLOR_RE.test(avatar_color)) {
return res.status(400).json({ error: 'Invalid avatar color' });
}
}
if (name && name !== req.user.name) {
const usernameRegex = /^[a-zA-Z0-9_-]{3,30}$/;
if (!usernameRegex.test(name)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, _ and - (330 chars)' });
}
const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?) AND id != ?', [name, req.user.id]);
if (existingUsername) {
return res.status(409).json({ error: 'Username is already taken' });
}
}
await db.run(`
UPDATE users SET
name = COALESCE(?, name),
display_name = COALESCE(?, display_name),
email = COALESCE(?, email),
theme = COALESCE(?, theme),
language = COALESCE(?, language),
avatar_color = COALESCE(?, avatar_color),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]);
`, [name, display_name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Profile update error:', err);
res.status(500).json({ error: 'Profil konnte nicht aktualisiert werden' });
res.status(500).json({ error: 'Profile could not be updated' });
}
});
// PUT /api/auth/password
router.put('/password', authenticateToken, async (req, res) => {
router.put('/password', authenticateToken, passwordLimiter, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
// M6: guard against missing/non-string body values
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'currentPassword and newPassword are required' });
}
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
const db = getDb();
const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
return res.status(401).json({ error: 'Current password is incorrect' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: 'Neues Passwort muss mindestens 6 Zeichen lang sein' });
// M4: minimum password length
if (newPassword.length < MIN_PASSWORD_LENGTH) {
return res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters long` });
}
const hash = bcrypt.hashSync(newPassword, 12);
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]);
res.json({ message: 'Passwort erfolgreich geändert' });
res.json({ message: 'Password changed successfully' });
} catch (err) {
console.error('Password change error:', err);
res.status(500).json({ error: 'Passwort konnte nicht geändert werden' });
res.status(500).json({ error: 'Password could not be changed' });
}
});
// POST /api/auth/avatar - Upload avatar image
router.post('/avatar', authenticateToken, async (req, res) => {
router.post('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
try {
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
// Validate content type
const contentType = req.headers['content-type'];
if (!contentType || !contentType.startsWith('image/')) {
return res.status(400).json({ error: 'Nur Bilddateien sind erlaubt' });
return res.status(400).json({ error: 'Only image files are allowed' });
}
// Max 2MB
if (buffer.length > 2 * 1024 * 1024) {
return res.status(400).json({ error: 'Bild darf maximal 2MB groß sein' });
// M15: stream-level size limit abort as soon as 2 MB is exceeded
const MAX_AVATAR_SIZE = 2 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
let totalSize = 0;
req.on('data', chunk => {
totalSize += chunk.length;
if (totalSize > MAX_AVATAR_SIZE) {
req.destroy();
return reject(new Error('LIMIT_EXCEEDED'));
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
}).catch(err => {
if (err.message === 'LIMIT_EXCEEDED') return null;
throw err;
});
if (!buffer) {
return res.status(400).json({ error: 'Image must not exceed 2MB' });
}
const ext = contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : contentType.includes('webp') ? 'webp' : 'jpg';
@@ -169,45 +503,96 @@ router.post('/avatar', authenticateToken, async (req, res) => {
const db = getDb();
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
if (current?.avatar_image) {
const oldPath = path.join(uploadsDir, current.avatar_image);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
// S8: defense-in-depth path traversal check on DB-stored filename
const oldPath = path.resolve(uploadsDir, current.avatar_image);
if (oldPath.startsWith(uploadsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
fs.writeFileSync(filepath, buffer);
await db.run('UPDATE users SET avatar_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [filename, req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Avatar upload error:', err);
res.status(500).json({ error: 'Avatar konnte nicht hochgeladen werden' });
res.status(500).json({ error: 'Avatar could not be uploaded' });
}
});
// DELETE /api/auth/avatar - Remove avatar image
router.delete('/avatar', authenticateToken, async (req, res) => {
router.delete('/avatar', authenticateToken, avatarLimiter, async (req, res) => {
try {
const db = getDb();
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
if (current?.avatar_image) {
const oldPath = path.join(uploadsDir, current.avatar_image);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
// S8: defense-in-depth path traversal check on DB-stored filename
const oldPath = path.resolve(uploadsDir, current.avatar_image);
if (oldPath.startsWith(uploadsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
const updated = await db.get('SELECT id, name, display_name, email, role, theme, language, avatar_color, avatar_image, email_verified FROM users WHERE id = ?', [req.user.id]);
res.json({ user: updated });
} catch (err) {
console.error('Avatar delete error:', err);
res.status(500).json({ error: 'Avatar konnte nicht entfernt werden' });
res.status(500).json({ error: 'Avatar could not be removed' });
}
});
// Escape XML special characters to prevent XSS in SVG text/attribute contexts
function escapeXml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// GET /api/auth/avatar/initials/:name - Generate SVG avatar from initials (public, BBB fetches this)
router.get('/avatar/initials/:name', (req, res) => {
const name = decodeURIComponent(req.params.name).trim();
// C1 fix: validate color against a strict allowlist before embedding in SVG attribute
const rawColor = req.query.color || '';
const color = SAFE_COLOR_RE.test(rawColor) ? rawColor : generateColorFromName(name);
// C2 fix: XML-escape initials before embedding in SVG text node
const rawInitials = name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2) || '?';
const initials = escapeXml(rawInitials);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="64" fill="${escapeXml(color)}"/>
<text x="64" y="64" dy=".35em" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="52" font-weight="bold">${initials}</text>
</svg>`;
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(svg);
});
function generateColorFromName(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 55%, 45%)`;
}
// GET /api/auth/avatar/:filename - Serve avatar image
router.get('/avatar/:filename', (req, res) => {
const filepath = path.join(uploadsDir, req.params.filename);
// H1 fix: resolve the path and ensure it stays inside uploadsDir (prevent path traversal)
const filepath = path.resolve(uploadsDir, req.params.filename);
if (!filepath.startsWith(uploadsDir + path.sep)) {
return res.status(400).json({ error: 'Invalid filename' });
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ error: 'Avatar nicht gefunden' });
return res.status(404).json({ error: 'Avatar not found' });
}
const ext = path.extname(filepath).slice(1);
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };

194
server/routes/branding.js Normal file
View File

@@ -0,0 +1,194 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const router = Router();
const SAFE_ID_RE = /^[a-zA-Z0-9_-]{1,50}$/;
// Ensure uploads/branding directory exists
const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding');
if (!fs.existsSync(brandingDir)) {
fs.mkdirSync(brandingDir, { recursive: true });
}
// Multer config for logo upload
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, brandingDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.png';
cb(null, `logo${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowed = /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i;
const mimeAllowed = /^image\/(jpeg|png|gif|svg\+xml|webp|x-icon|vnd\.microsoft\.icon)$/;
if (allowed.test(path.extname(file.originalname)) && mimeAllowed.test(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed'));
}
},
});
// Helper: get setting from DB
async function getSetting(key) {
const db = getDb();
const row = await db.get('SELECT value FROM settings WHERE key = ?', [key]);
return row?.value || null;
}
// Helper: set setting in DB
async function setSetting(key, value) {
const db = getDb();
// Try update first, then insert if nothing was updated
const result = await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]);
if (result.changes === 0) {
// Use INSERT with a dummy RETURNING to satisfy PG adapter, or just use exec-style
await db.run('INSERT INTO settings (key, value) VALUES (?, ?) RETURNING key', [key, value]);
}
}
// Helper: delete setting from DB
async function deleteSetting(key) {
const db = getDb();
await db.run('DELETE FROM settings WHERE key = ?', [key]);
}
// Helper: find current logo file on disk
function findLogoFile() {
if (!fs.existsSync(brandingDir)) return null;
const files = fs.readdirSync(brandingDir);
const logo = files.find(f => f.startsWith('logo.'));
return logo ? path.join(brandingDir, logo) : null;
}
// GET /api/branding - Get branding settings (public)
router.get('/', async (req, res) => {
try {
const appName = await getSetting('app_name');
const defaultTheme = await getSetting('default_theme');
const logoFile = findLogoFile();
res.json({
appName: appName || 'Redlight',
hasLogo: !!logoFile,
logoUrl: logoFile ? '/api/branding/logo' : null,
defaultTheme: defaultTheme || null,
});
} catch (err) {
console.error('Get branding error:', err);
res.status(500).json({ error: 'Could not load branding' });
}
});
// GET /api/branding/logo - Serve logo file (public)
router.get('/logo', (req, res) => {
const logoFile = findLogoFile();
if (!logoFile) {
return res.status(404).json({ error: 'No logo found' });
}
// H5: serve SVG as attachment (Content-Disposition) to prevent in-browser script execution.
// For non-SVG images, inline display is fine.
const ext = path.extname(logoFile).toLowerCase();
if (ext === '.svg') {
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Content-Disposition', 'attachment; filename="logo.svg"');
res.setHeader('X-Content-Type-Options', 'nosniff');
return res.sendFile(logoFile);
}
res.sendFile(logoFile);
});
// POST /api/branding/logo - Upload logo (admin only)
router.post('/logo', authenticateToken, requireAdmin, (req, res) => {
upload.single('logo')(req, res, async (err) => {
if (err) {
if (err instanceof multer.MulterError) {
return res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 5MB)' : err.message });
}
return res.status(400).json({ error: err.message });
}
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
// Remove old logo files that don't match the new extension
const files = fs.readdirSync(brandingDir);
for (const f of files) {
if (f.startsWith('logo.') && f !== req.file.filename) {
fs.unlinkSync(path.join(brandingDir, f));
}
}
res.json({
logoUrl: '/api/branding/logo',
message: 'Logo uploaded',
});
});
});
// DELETE /api/branding/logo - Remove logo (admin only)
router.delete('/logo', authenticateToken, requireAdmin, async (req, res) => {
try {
const logoFile = findLogoFile();
if (logoFile) {
fs.unlinkSync(logoFile);
}
res.json({ message: 'Logo removed' });
} catch (err) {
console.error('Delete logo error:', err);
res.status(500).json({ error: 'Could not remove logo' });
}
});
// PUT /api/branding/name - Update app name (admin only)
router.put('/name', authenticateToken, requireAdmin, async (req, res) => {
try {
const { appName } = req.body;
if (!appName || !appName.trim()) {
return res.status(400).json({ error: 'App name is required' });
}
if (appName.trim().length > 100) {
return res.status(400).json({ error: 'App name must not exceed 100 characters' });
}
await setSetting('app_name', appName.trim());
res.json({ appName: appName.trim() });
} catch (err) {
console.error('Update app name error:', err);
res.status(500).json({ error: 'Could not update app name' });
}
});
// PUT /api/branding/default-theme - Set default theme for unauthenticated pages (admin only)
router.put('/default-theme', authenticateToken, requireAdmin, async (req, res) => {
try {
const { defaultTheme } = req.body;
if (!defaultTheme || !defaultTheme.trim()) {
return res.status(400).json({ error: 'defaultTheme is required' });
}
// Basic format validation for theme ID
if (!SAFE_ID_RE.test(defaultTheme.trim())) {
return res.status(400).json({ error: 'Invalid theme' });
}
await setSetting('default_theme', defaultTheme.trim());
res.json({ defaultTheme: defaultTheme.trim() });
} catch (err) {
console.error('Update default theme error:', err);
res.status(500).json({ error: 'Could not update default theme' });
}
});
export default router;

363
server/routes/federation.js Normal file
View File

@@ -0,0 +1,363 @@
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { sendFederationInviteEmail } from '../config/mailer.js';
// M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many federation requests. Please try again later.' },
});
import {
getFederationDomain,
isFederationEnabled,
getPublicKey,
signPayload,
verifyPayload,
discoverInstance,
parseAddress,
} from '../config/federation.js';
const router = Router();
// ── Well-known discovery endpoint ───────────────────────────────────────────
// Mounted at /.well-known/redlight in index.js
export function wellKnownHandler(req, res) {
const domain = getFederationDomain();
if (!domain) {
return res.status(404).json({ error: 'Federation not configured' });
}
res.json({
domain,
federation_api: '/api/federation',
public_key: getPublicKey(),
software: 'Redlight',
version: '1.2.0',
});
}
// ── POST /api/federation/invite — Send invitation to remote user ────────────
router.post('/invite', authenticateToken, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const { room_uid, to, message } = req.body;
if (!room_uid || !to) {
return res.status(400).json({ error: 'room_uid and to are required' });
}
const { username, domain } = parseAddress(to);
if (!domain) {
return res.status(400).json({ error: 'Remote address must be in format username@domain' });
}
// Don't allow inviting to own instance
if (domain === getFederationDomain()) {
return res.status(400).json({ error: 'Cannot send federation invite to your own instance. Use local sharing instead.' });
}
const db = getDb();
// Verify room exists and user has access
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [room_uid]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
const isOwner = room.user_id === req.user.id;
if (!isOwner) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to invite from this room' });
}
}
// Build guest join URL for the remote user
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const joinUrl = `${baseUrl}/join/${room.uid}`;
// Build invitation payload
const inviteId = uuidv4();
const payload = {
invite_id: inviteId,
from_user: `@${req.user.name}@${getFederationDomain()}`,
to_user: to,
room_name: room.name,
room_uid: room.uid,
max_participants: room.max_participants ?? 0,
allow_recording: room.record_meeting ?? 1,
message: message || null,
join_url: joinUrl,
timestamp: new Date().toISOString(),
};
// Sign and send to remote instance
const signature = signPayload(payload);
const { baseUrl: remoteApi } = await discoverInstance(domain);
const response = await fetch(`${remoteApi}/receive`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Federation-Signature': signature,
'X-Federation-Origin': getFederationDomain(),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000), // 15 second timeout
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Remote server responded with ${response.status}`);
}
res.json({ success: true, invite_id: inviteId });
} catch (err) {
console.error('Federation invite error:', err);
res.status(500).json({ error: err.message || 'Failed to send federation invite' });
}
});
// ── POST /api/federation/receive — Accept incoming invitation from remote ───
router.post('/receive', federationReceiveLimiter, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const payload = req.body || {};
if (!signature) {
return res.status(401).json({ error: 'Missing federation signature' });
}
// Extract expected fields from the incoming payload
const { invite_id, from_user, to_user, room_name, room_uid, max_participants, allow_recording, message, join_url } = payload;
if (!invite_id || !from_user || !to_user || !room_name || !join_url) {
return res.status(400).json({ error: 'Incomplete invitation payload' });
}
// S4: validate field lengths from remote to prevent oversized DB entries
if (invite_id.length > 100 || from_user.length > 200 || to_user.length > 200 ||
room_name.length > 200 || join_url.length > 2000 || (message && message.length > 5000)) {
return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' });
}
// Fetch the sender's public key dynamically
const { domain: senderDomain } = parseAddress(from_user);
if (!senderDomain) {
return res.status(400).json({ error: 'Sender address must include a domain' });
}
const { publicKey } = await discoverInstance(senderDomain);
if (!publicKey) {
return res.status(400).json({ error: 'Sender instance did not provide a public key' });
}
if (!verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation signature' });
}
// Parse the target address and find local user
const { username } = parseAddress(to_user);
const db = getDb();
// Look up user by name (case-insensitive)
const targetUser = await db.get(
'SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)',
[username]
);
if (!targetUser) {
return res.status(404).json({ error: 'User not found on this instance' });
}
// Check for duplicate
const existing = await db.get(
'SELECT id FROM federation_invitations WHERE invite_id = ?',
[invite_id]
);
if (existing) {
return res.json({ success: true, message: 'Invitation already received' });
}
// Store the invitation
await db.run(
`INSERT INTO federation_invitations (invite_id, from_user, to_user_id, room_name, message, join_url)
VALUES (?, ?, ?, ?, ?, ?)`,
[invite_id, from_user, targetUser.id, room_name, message || null, join_url]
);
// Store room_uid, max_participants, allow_recording if those columns already exist
// (we update after initial insert to stay compatible with old schema)
const inv = await db.get('SELECT id FROM federation_invitations WHERE invite_id = ? AND to_user_id = ?', [invite_id, targetUser.id]);
if (inv && room_uid !== undefined) {
try {
await db.run(
'UPDATE federation_invitations SET room_uid = ?, max_participants = ?, allow_recording = ? WHERE id = ?',
[room_uid || null, max_participants ?? 0, allow_recording ?? 1, inv.id]
);
} catch { /* column may not exist on very old installs */ }
}
// Send notification email (truly fire-and-forget never blocks the response)
if (targetUser.email) {
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName
).catch(mailErr => {
console.warn('Federation invite mail failed (non-fatal):', mailErr.message);
});
}
res.json({ success: true });
} catch (err) {
console.error('Federation receive error:', err);
res.status(500).json({ error: 'Failed to process federation invitation' });
}
});
// ── GET /api/federation/invitations — List invitations for current user ─────
router.get('/invitations', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitations = await db.all(
`SELECT * FROM federation_invitations
WHERE to_user_id = ?
ORDER BY created_at DESC`,
[req.user.id]
);
res.json({ invitations });
} catch (err) {
console.error('List federation invitations error:', err);
res.status(500).json({ error: 'Failed to load invitations' });
}
});
// ── GET /api/federation/invitations/pending-count — Badge count ─────────────
router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
try {
const db = getDb();
const result = await db.get(
`SELECT COUNT(*) as count FROM federation_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
res.json({ count: result?.count || 0 });
} catch (err) {
res.json({ count: 0 });
}
});
// ── POST /api/federation/invitations/:id/accept — Accept an invitation ──────
router.post('/invitations/:id/accept', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitation = await db.get(
'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?',
[req.params.id, req.user.id]
);
if (!invitation) {
return res.status(404).json({ error: 'Invitation not found' });
}
await db.run(
"UPDATE federation_invitations SET status = 'accepted' WHERE id = ?",
[invitation.id]
);
// Upsert into federated_rooms so the room appears in the user's dashboard
const existing = await db.get(
'SELECT id FROM federated_rooms WHERE invite_id = ? AND user_id = ?',
[invitation.invite_id, req.user.id]
);
if (!existing) {
await db.run(
`INSERT INTO federated_rooms (user_id, invite_id, room_name, from_user, join_url, meet_id, max_participants, allow_recording)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
req.user.id, invitation.invite_id, invitation.room_name,
invitation.from_user, invitation.join_url,
invitation.room_uid || null,
invitation.max_participants ?? 0,
invitation.allow_recording ?? 1,
]
);
}
res.json({ success: true, join_url: invitation.join_url });
} catch (err) {
console.error('Accept invitation error:', err);
res.status(500).json({ error: 'Failed to accept invitation' });
}
});
// ── DELETE /api/federation/invitations/:id — Decline/dismiss invitation ─────
router.delete('/invitations/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitation = await db.get(
'SELECT * FROM federation_invitations WHERE id = ? AND to_user_id = ?',
[req.params.id, req.user.id]
);
if (!invitation) {
return res.status(404).json({ error: 'Invitation not found' });
}
await db.run('DELETE FROM federation_invitations WHERE id = ?', [invitation.id]);
res.json({ success: true });
} catch (err) {
console.error('Decline invitation error:', err);
res.status(500).json({ error: 'Failed to decline invitation' });
}
});
// ── GET /api/federation/federated-rooms — List saved federated rooms ────────
router.get('/federated-rooms', authenticateToken, async (req, res) => {
try {
const db = getDb();
const rooms = await db.all(
`SELECT * FROM federated_rooms WHERE user_id = ? ORDER BY created_at DESC`,
[req.user.id]
);
res.json({ rooms });
} catch (err) {
console.error('List federated rooms error:', err);
res.status(500).json({ error: 'Failed to load federated rooms' });
}
});
// ── DELETE /api/federation/federated-rooms/:id — Remove a federated room ────
router.delete('/federated-rooms/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get(
'SELECT * FROM federated_rooms WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id]
);
if (!room) return res.status(404).json({ error: 'Room not found' });
await db.run('DELETE FROM federated_rooms WHERE id = ?', [room.id]);
res.json({ success: true });
} catch (err) {
console.error('Delete federated room error:', err);
res.status(500).json({ error: 'Failed to remove room' });
}
});
export default router;

View File

@@ -3,6 +3,7 @@ import { authenticateToken } from '../middleware/auth.js';
import { getDb } from '../config/database.js';
import {
getRecordings,
getRecordingByRecordId,
deleteRecording,
publishRecording,
} from '../config/bbb.js';
@@ -13,6 +14,25 @@ const router = Router();
router.get('/', authenticateToken, async (req, res) => {
try {
const { meetingID } = req.query;
// M11: verify user has access to the room if a meetingID is specified
if (meetingID) {
const db = getDb();
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [meetingID]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to view recordings for this room' });
}
}
} else if (req.user.role !== 'admin') {
// Non-admins must specify a meetingID
return res.status(400).json({ error: 'meetingID query parameter is required' });
}
const recordings = await getRecordings(meetingID || undefined);
// Format recordings
@@ -26,7 +46,7 @@ router.get('/', authenticateToken, async (req, res) => {
return {
recordID: rec.recordID,
meetingID: rec.meetingID,
name: rec.name || 'Aufnahme',
name: rec.name || 'Recording',
state: rec.state,
published: rec.published === 'true',
startTime: rec.startTime,
@@ -46,7 +66,7 @@ router.get('/', authenticateToken, async (req, res) => {
res.json({ recordings: formatted });
} catch (err) {
console.error('Get recordings error:', err);
res.status(500).json({ error: 'Aufnahmen konnten nicht geladen werden', recordings: [] });
res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] });
}
});
@@ -57,7 +77,15 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
return res.status(404).json({ error: 'Room not found' });
}
// H9: verify requesting user has access to this room
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to view recordings for this room' });
}
}
const recordings = await getRecordings(room.uid);
@@ -90,30 +118,68 @@ router.get('/room/:uid', authenticateToken, async (req, res) => {
res.json({ recordings: formatted });
} catch (err) {
console.error('Get room recordings error:', err);
res.status(500).json({ error: 'Aufnahmen konnten nicht geladen werden', recordings: [] });
res.status(500).json({ error: 'Recordings could not be loaded', recordings: [] });
}
});
// DELETE /api/recordings/:recordID
router.delete('/:recordID', authenticateToken, async (req, res) => {
try {
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
// then verify the user owns or shares that room.
if (req.user.role !== 'admin') {
const rec = await getRecordingByRecordId(req.params.recordID);
if (!rec) {
return res.status(404).json({ error: 'Recording not found' });
}
const db = getDb();
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
if (room.user_id !== req.user.id) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to delete this recording' });
}
}
}
await deleteRecording(req.params.recordID);
res.json({ message: 'Aufnahme gelöscht' });
res.json({ message: 'Recording deleted' });
} catch (err) {
console.error('Delete recording error:', err);
res.status(500).json({ error: 'Aufnahme konnte nicht gelöscht werden' });
res.status(500).json({ error: 'Recording could not be deleted' });
}
});
// PUT /api/recordings/:recordID/publish
router.put('/:recordID/publish', authenticateToken, async (req, res) => {
try {
// M14 fix: look up the recording from BBB to find its meetingID (room UID),
// then verify the user owns or shares that room.
if (req.user.role !== 'admin') {
const rec = await getRecordingByRecordId(req.params.recordID);
if (!rec) {
return res.status(404).json({ error: 'Recording not found' });
}
const db = getDb();
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [rec.meetingID]);
if (!room) {
return res.status(404).json({ error: 'Room not found' });
}
if (room.user_id !== req.user.id) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission to update this recording' });
}
}
}
const { publish } = req.body;
await publishRecording(req.params.recordID, publish);
res.json({ message: publish ? 'Aufnahme veröffentlicht' : 'Aufnahme nicht mehr öffentlich' });
res.json({ message: publish ? 'Recording published' : 'Recording unpublished' });
} catch (err) {
console.error('Publish recording error:', err);
res.status(500).json({ error: 'Aufnahme konnte nicht aktualisiert werden' });
res.status(500).json({ error: 'Recording could not be updated' });
}
});

View File

@@ -1,5 +1,9 @@
import { Router } from 'express';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import {
@@ -10,24 +14,88 @@ import {
isMeetingRunning,
} from '../config/bbb.js';
// L6: constant-time string comparison for access/moderator codes
function timingSafeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const presentationsDir = path.join(__dirname, '..', '..', 'uploads', 'presentations');
if (!fs.existsSync(presentationsDir)) fs.mkdirSync(presentationsDir, { recursive: true });
// M8: rate limit unauthenticated guest-join to prevent access_code brute-force
const guestJoinLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 15,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many join attempts. Please try again later.' },
});
const router = Router();
// GET /api/rooms - List user's rooms
// Build avatar URL for a user (uploaded image or generated initials)
function getUserAvatarURL(req, user) {
const baseUrl = `${req.protocol}://${req.get('host')}`;
if (user.avatar_image) {
return `${baseUrl}/api/auth/avatar/${user.avatar_image}`;
}
const color = user.avatar_color ? `?color=${encodeURIComponent(user.avatar_color)}` : '';
return `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(user.display_name || user.name)}${color}`;
}
// GET /api/rooms - List user's rooms (owned + shared)
router.get('/', authenticateToken, async (req, res) => {
try {
const db = getDb();
const rooms = await db.all(`
SELECT r.*, u.name as owner_name
const ownRooms = await db.all(`
SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name, 0 as shared
FROM rooms r
JOIN users u ON r.user_id = u.id
WHERE r.user_id = ?
ORDER BY r.created_at DESC
`, [req.user.id]);
res.json({ rooms });
const sharedRooms = await db.all(`
SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name, 1 as shared
FROM rooms r
JOIN users u ON r.user_id = u.id
JOIN room_shares rs ON rs.room_id = r.id
WHERE rs.user_id = ?
ORDER BY r.created_at DESC
`, [req.user.id]);
res.json({ rooms: [...ownRooms, ...sharedRooms] });
} catch (err) {
console.error('List rooms error:', err);
res.status(500).json({ error: 'Räume konnten nicht geladen werden' });
res.status(500).json({ error: 'Rooms could not be loaded' });
}
});
// GET /api/rooms/users/search - Search users for sharing (must be before /:uid routes)
router.get('/users/search', authenticateToken, async (req, res) => {
try {
const { q } = req.query;
if (!q || q.length < 2) {
return res.json({ users: [] });
}
const db = getDb();
const searchTerm = `%${q}%`;
const users = await db.all(`
SELECT id, name, display_name, email, avatar_color, avatar_image
FROM users
WHERE (name LIKE ? OR display_name LIKE ? OR email LIKE ?) AND id != ?
LIMIT 10
`, [searchTerm, searchTerm, searchTerm, req.user.id]);
res.json({ users });
} catch (err) {
console.error('Search users error:', err);
res.status(500).json({ error: 'User search failed' });
}
});
@@ -36,20 +104,37 @@ router.get('/:uid', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get(`
SELECT r.*, u.name as owner_name
SELECT r.*, COALESCE(NULLIF(u.display_name,''), u.name) as owner_name
FROM rooms r
JOIN users u ON r.user_id = u.id
WHERE r.uid = ?
`, [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
return res.status(404).json({ error: 'Room not found' });
}
res.json({ room });
// Check access: owner, admin, or shared
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission' });
}
room.shared = 1;
}
// Get shared users
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
res.json({ room, sharedUsers });
} catch (err) {
console.error('Get room error:', err);
res.status(500).json({ error: 'Raum konnte nicht geladen werden' });
res.status(500).json({ error: 'Room could not be loaded' });
}
});
@@ -71,7 +156,28 @@ router.post('/', authenticateToken, async (req, res) => {
} = req.body;
if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Raumname ist erforderlich' });
return res.status(400).json({ error: 'Room name is required' });
}
// M7: field length limits
if (name.trim().length > 100) {
return res.status(400).json({ error: 'Room name must not exceed 100 characters' });
}
if (welcome_message && welcome_message.length > 2000) {
return res.status(400).json({ error: 'Welcome message must not exceed 2000 characters' });
}
if (access_code && access_code.length > 50) {
return res.status(400).json({ error: 'Access code must not exceed 50 characters' });
}
if (moderator_code && moderator_code.length > 50) {
return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' });
}
// S2: validate max_participants as non-negative integer
if (max_participants !== undefined && max_participants !== null) {
const mp = Number(max_participants);
if (!Number.isInteger(mp) || mp < 0 || mp > 10000) {
return res.status(400).json({ error: 'max_participants must be a non-negative integer (max 10000)' });
}
}
const uid = crypto.randomBytes(8).toString('hex');
@@ -100,7 +206,7 @@ router.post('/', authenticateToken, async (req, res) => {
res.status(201).json({ room });
} catch (err) {
console.error('Create room error:', err);
res.status(500).json({ error: 'Raum konnte nicht erstellt werden' });
res.status(500).json({ error: 'Room could not be created' });
}
});
@@ -111,7 +217,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
return res.status(404).json({ error: 'Room not found or no permission' });
}
const {
@@ -128,6 +234,27 @@ router.put('/:uid', authenticateToken, async (req, res) => {
moderator_code,
} = req.body;
// M12: field length limits (same as create)
if (name && name.trim().length > 100) {
return res.status(400).json({ error: 'Room name must not exceed 100 characters' });
}
if (welcome_message && welcome_message.length > 2000) {
return res.status(400).json({ error: 'Welcome message must not exceed 2000 characters' });
}
if (access_code && access_code.length > 50) {
return res.status(400).json({ error: 'Access code must not exceed 50 characters' });
}
if (moderator_code && moderator_code.length > 50) {
return res.status(400).json({ error: 'Moderator code must not exceed 50 characters' });
}
// S2: validate max_participants on update
if (max_participants !== undefined && max_participants !== null) {
const mp = Number(max_participants);
if (!Number.isInteger(mp) || mp < 0 || mp > 10000) {
return res.status(400).json({ error: 'max_participants must be a non-negative integer (max 10000)' });
}
}
await db.run(`
UPDATE rooms SET
name = COALESCE(?, name),
@@ -162,7 +289,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
res.json({ room: updated });
} catch (err) {
console.error('Update room error:', err);
res.status(500).json({ error: 'Raum konnte nicht aktualisiert werden' });
res.status(500).json({ error: 'Room could not be updated' });
}
});
@@ -173,18 +300,94 @@ router.delete('/:uid', authenticateToken, async (req, res) => {
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
return res.status(404).json({ error: 'Room not found' });
}
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Keine Berechtigung' });
return res.status(403).json({ error: 'No permission' });
}
await db.run('DELETE FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ message: 'Raum erfolgreich gelöscht' });
res.json({ message: 'Room deleted successfully' });
} catch (err) {
console.error('Delete room error:', err);
res.status(500).json({ error: 'Raum konnte nicht gelöscht werden' });
res.status(500).json({ error: 'Room could not be deleted' });
}
});
// GET /api/rooms/:uid/shares - Get shared users for a room
router.get('/:uid/shares', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) {
return res.status(404).json({ error: 'Room not found or no permission' });
}
const shares = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
res.json({ shares });
} catch (err) {
console.error('Get shares error:', err);
res.status(500).json({ error: 'Error loading shares' });
}
});
// POST /api/rooms/:uid/shares - Share room with a user
router.post('/:uid/shares', authenticateToken, async (req, res) => {
try {
const { user_id } = req.body;
if (!user_id) {
return res.status(400).json({ error: 'User ID is required' });
}
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) {
return res.status(404).json({ error: 'Room not found or no permission' });
}
if (user_id === req.user.id) {
return res.status(400).json({ error: 'You cannot share the room with yourself' });
}
// Check if already shared
const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]);
if (existing) {
return res.status(400).json({ error: 'Room is already shared with this user' });
}
await db.run('INSERT INTO room_shares (room_id, user_id) VALUES (?, ?)', [room.id, user_id]);
const shares = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
res.json({ shares });
} catch (err) {
console.error('Share room error:', err);
res.status(500).json({ error: 'Error sharing room' });
}
});
// DELETE /api/rooms/:uid/shares/:userId - Remove share
router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) {
return res.status(404).json({ error: 'Room not found or no permission' });
}
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
const shares = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM room_shares rs
JOIN users u ON rs.user_id = u.id
WHERE rs.room_id = ?
`, [room.id]);
res.json({ shares });
} catch (err) {
console.error('Remove share error:', err);
res.status(500).json({ error: 'Error removing share' });
}
});
@@ -192,18 +395,34 @@ router.delete('/:uid', authenticateToken, async (req, res) => {
router.post('/:uid/start', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
return res.status(404).json({ error: 'Room not found' });
}
await createMeeting(room);
const joinUrl = await joinMeeting(room.uid, req.user.name, true);
// Check access: owner or shared
const isOwner = room.user_id === req.user.id;
if (!isOwner) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission' });
}
}
const baseUrl = `${req.protocol}://${req.get('host')}`;
const loginURL = `${baseUrl}/join/${room.uid}`;
const presentationUrl = room.presentation_file
? `${baseUrl}/uploads/presentations/${room.presentation_file}`
: null;
await createMeeting(room, baseUrl, loginURL, presentationUrl);
const avatarURL = getUserAvatarURL(req, req.user);
const displayName = req.user.display_name || req.user.name;
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL);
res.json({ joinUrl });
} catch (err) {
console.error('Start meeting error:', err);
res.status(500).json({ error: 'Meeting konnte nicht gestartet werden' });
res.status(500).json({ error: 'Meeting could not be started' });
}
});
@@ -214,26 +433,30 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
return res.status(404).json({ error: 'Room not found' });
}
// Check access code if set
if (room.access_code && req.body.access_code !== room.access_code) {
return res.status(403).json({ error: 'Falscher Zugangscode' });
if (room.access_code && !timingSafeEqual(req.body.access_code || '', room.access_code)) {
return res.status(403).json({ error: 'Wrong access code' });
}
// Check if meeting is running
const running = await isMeetingRunning(room.uid);
if (!running) {
return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' });
return res.status(400).json({ error: 'Meeting is not running. Please wait for the moderator to start the meeting.' });
}
const isModerator = room.user_id === req.user.id || room.all_join_moderator;
const joinUrl = await joinMeeting(room.uid, req.user.name, isModerator);
// Owner and shared users join as moderator
const isOwner = room.user_id === req.user.id;
const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
const isModerator = isOwner || !!isShared || room.all_join_moderator;
const avatarURL = getUserAvatarURL(req, req.user);
const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL);
res.json({ joinUrl });
} catch (err) {
console.error('Join meeting error:', err);
res.status(500).json({ error: 'Meeting konnte nicht beigetreten werden' });
res.status(500).json({ error: 'Could not join meeting' });
}
});
@@ -241,17 +464,26 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
router.post('/:uid/end', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
return res.status(404).json({ error: 'Room not found' });
}
// Check access: owner or shared user
const isOwner = room.user_id === req.user.id;
if (!isOwner) {
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
if (!share) {
return res.status(403).json({ error: 'No permission' });
}
}
await endMeeting(room.uid);
res.json({ message: 'Meeting beendet' });
res.json({ message: 'Meeting ended' });
} catch (err) {
console.error('End meeting error:', err);
res.status(500).json({ error: 'Meeting konnte nicht beendet werden' });
res.status(500).json({ error: 'Meeting could not be ended' });
}
});
@@ -260,19 +492,15 @@ router.get('/:uid/public', async (req, res) => {
try {
const db = getDb();
const room = await db.get(`
SELECT r.uid, r.name, r.guest_access, r.welcome_message, r.access_code,
u.name as owner_name
SELECT r.uid, r.name, r.welcome_message, r.access_code, r.record_meeting, r.max_participants, r.anyone_can_start,
COALESCE(NULLIF(u.display_name,''), u.name) as owner_name
FROM rooms r
JOIN users u ON r.user_id = u.id
WHERE r.uid = ?
`, [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
}
if (!room.guest_access) {
return res.status(403).json({ error: 'Gastzugang ist für diesen Raum nicht aktiviert' });
return res.status(404).json({ error: 'Room not found' });
}
const running = await isMeetingRunning(room.uid);
@@ -284,62 +512,70 @@ router.get('/:uid/public', async (req, res) => {
owner_name: room.owner_name,
welcome_message: room.welcome_message,
has_access_code: !!room.access_code,
allow_recording: !!room.record_meeting,
max_participants: room.max_participants ?? 0,
anyone_can_start: !!room.anyone_can_start,
},
running,
});
} catch (err) {
console.error('Public room info error:', err);
res.status(500).json({ error: 'Rauminfos konnten nicht geladen werden' });
res.status(500).json({ error: 'Room info could not be loaded' });
}
});
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
router.post('/:uid/guest-join', async (req, res) => {
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
try {
const { name, access_code, moderator_code } = req.body;
if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Name ist erforderlich' });
return res.status(400).json({ error: 'Name is required' });
}
// L1: limit guest name length
if (name.trim().length > 100) {
return res.status(400).json({ error: 'Name must not exceed 100 characters' });
}
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
if (!room) {
return res.status(404).json({ error: 'Raum nicht gefunden' });
}
if (!room.guest_access) {
return res.status(403).json({ error: 'Gastzugang ist für diesen Raum nicht aktiviert' });
return res.status(404).json({ error: 'Room not found' });
}
// Check access code if set
if (room.access_code && access_code !== room.access_code) {
return res.status(403).json({ error: 'Falscher Zugangscode' });
if (room.access_code && !timingSafeEqual(access_code || '', room.access_code)) {
return res.status(403).json({ error: 'Wrong access code' });
}
// Check if meeting is running (or if anyone_can_start is enabled)
const running = await isMeetingRunning(room.uid);
if (!running && !room.anyone_can_start) {
return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' });
return res.status(400).json({ error: 'Meeting is not running. Please wait for the moderator to start the meeting.' });
}
// If meeting not running but anyone_can_start, create it
if (!running && room.anyone_can_start) {
await createMeeting(room);
const baseUrl = `${req.protocol}://${req.get('host')}`;
const loginURL = `${baseUrl}/join/${room.uid}`;
await createMeeting(room, baseUrl, loginURL);
}
// Check moderator code
let isModerator = !!room.all_join_moderator;
if (!isModerator && moderator_code && room.moderator_code && moderator_code === room.moderator_code) {
if (!isModerator && moderator_code && room.moderator_code && timingSafeEqual(moderator_code, room.moderator_code)) {
isModerator = true;
}
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator);
const baseUrl = `${req.protocol}://${req.get('host')}`;
const guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
res.json({ joinUrl });
} catch (err) {
console.error('Guest join error:', err);
res.status(500).json({ error: 'Beitritt als Gast fehlgeschlagen' });
res.status(500).json({ error: 'Guest join failed' });
}
});
@@ -365,4 +601,95 @@ router.get('/:uid/status', async (req, res) => {
}
});
// POST /api/rooms/:uid/presentation - Upload a presentation file for the room
router.post('/:uid/presentation', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) return res.status(404).json({ error: 'Room not found or no permission' });
// M16: stream-level size limit abort as soon as 50 MB is exceeded
const MAX_PRESENTATION_SIZE = 50 * 1024 * 1024;
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
let totalSize = 0;
req.on('data', chunk => {
totalSize += chunk.length;
if (totalSize > MAX_PRESENTATION_SIZE) {
req.destroy();
return reject(new Error('LIMIT_EXCEEDED'));
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
}).catch(err => {
if (err.message === 'LIMIT_EXCEEDED') return null;
throw err;
});
if (!buffer) {
return res.status(400).json({ error: 'File must not exceed 50MB' });
}
const contentType = req.headers['content-type'] || '';
const extMap = {
'application/pdf': 'pdf',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'application/vnd.oasis.opendocument.presentation': 'odp',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
};
const ext = extMap[contentType];
if (!ext) return res.status(400).json({ error: 'Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX' });
// Preserve original filename (sent as X-Filename header)
const rawName = req.headers['x-filename'];
const originalName = rawName
? decodeURIComponent(rawName).replace(/[^a-zA-Z0-9._\- ]/g, '_').slice(0, 200)
: `presentation.${ext}`;
const filename = `${room.uid}_${Date.now()}.${ext}`;
const filepath = path.join(presentationsDir, filename);
// Remove old presentation file if exists
if (room.presentation_file) {
// S8: defense-in-depth path traversal check
const oldPath = path.resolve(presentationsDir, room.presentation_file);
if (oldPath.startsWith(presentationsDir + path.sep) && fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
fs.writeFileSync(filepath, buffer);
await db.run('UPDATE rooms SET presentation_file = ?, presentation_name = ?, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [filename, originalName, req.params.uid]);
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated });
} catch (err) {
console.error('Presentation upload error:', err);
res.status(500).json({ error: 'Presentation could not be uploaded' });
}
});
// DELETE /api/rooms/:uid/presentation - Remove presentation file
router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
try {
const db = getDb();
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
if (!room) return res.status(404).json({ error: 'Room not found or no permission' });
if (room.presentation_file) {
// S8: defense-in-depth path traversal check
const filepath = path.resolve(presentationsDir, room.presentation_file);
if (filepath.startsWith(presentationsDir + path.sep) && fs.existsSync(filepath)) fs.unlinkSync(filepath);
}
await db.run('UPDATE rooms SET presentation_file = NULL, presentation_name = NULL, updated_at = CURRENT_TIMESTAMP WHERE uid = ?', [req.params.uid]);
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
res.json({ room: updated });
} catch (err) {
console.error('Presentation delete error:', err);
res.status(500).json({ error: 'Presentation could not be removed' });
}
});
export default router;

View File

@@ -2,20 +2,25 @@ import { useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import { useLanguage } from './contexts/LanguageContext';
import { useBranding } from './contexts/BrandingContext';
import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute';
import Home from './pages/Home';
import Login from './pages/Login';
import Register from './pages/Register';
import VerifyEmail from './pages/VerifyEmail';
import Dashboard from './pages/Dashboard';
import RoomDetail from './pages/RoomDetail';
import Settings from './pages/Settings';
import Admin from './pages/Admin';
import GuestJoin from './pages/GuestJoin';
import FederationInbox from './pages/FederationInbox';
import FederatedRoomDetail from './pages/FederatedRoomDetail';
export default function App() {
const { user, loading } = useAuth();
const { setLanguage } = useLanguage();
const { appName } = useBranding();
// Sync language from server when user loads
useEffect(() => {
@@ -24,6 +29,11 @@ export default function App() {
}
}, [user?.language, setLanguage]);
// Update document title with branding
useEffect(() => {
document.title = `${appName} - BigBlueButton Frontend`;
}, [appName]);
if (loading) {
return (
<div className="min-h-screen bg-th-bg flex items-center justify-center">
@@ -38,6 +48,7 @@ export default function App() {
<Route path="/" element={user ? <Navigate to="/dashboard" /> : <Home />} />
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/join/:uid" element={<GuestJoin />} />
{/* Protected routes */}
@@ -46,6 +57,8 @@ export default function App() {
<Route path="/rooms/:uid" element={<RoomDetail />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
<Route path="/federation/inbox" element={<FederationInbox />} />
<Route path="/federation/rooms/:id" element={<FederatedRoomDetail />} />
</Route>
{/* Catch all */}

View File

@@ -0,0 +1,35 @@
import { Video } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext';
const sizes = {
sm: { box: 'w-8 h-8', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
};
export default function BrandLogo({ size = 'md', className = '' }) {
const { appName, hasLogo, logoUrl } = useBranding();
const s = sizes[size] || sizes.md;
if (hasLogo && logoUrl) {
return (
<div className={`flex items-center gap-2.5 ${className}`}>
<img
src={logoUrl}
alt={appName}
className={`${s.box} ${s.rounded} object-contain`}
/>
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
</div>
);
}
return (
<div className={`flex items-center gap-2.5 ${className}`}>
<div className={`${s.box} gradient-bg ${s.rounded} flex items-center justify-center`}>
<Video size={s.icon} className="text-white" />
</div>
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { Globe, Trash2, ExternalLink, Hash, Users, Video, VideoOff } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import api from '../services/api';
import toast from 'react-hot-toast';
export default function FederatedRoomCard({ room, onRemove }) {
const { t } = useLanguage();
const navigate = useNavigate();
const handleJoin = (e) => {
e.stopPropagation();
window.open(room.join_url, '_blank');
};
const handleRemove = async (e) => {
e.stopPropagation();
if (!confirm(t('federation.removeRoomConfirm'))) return;
try {
await api.delete(`/federation/federated-rooms/${room.id}`);
toast.success(t('federation.roomRemoved'));
onRemove?.();
} catch {
toast.error(t('federation.roomRemoveFailed'));
}
};
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
return (
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/federation/rooms/${room.id}`)}>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Globe size={14} className="text-th-accent flex-shrink-0" />
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
{room.room_name}
</h3>
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
{t('federation.federated')}
</span>
</div>
<p className="text-sm text-th-text-s mt-0.5 truncate">
{t('federation.from')}: <span className="font-medium">{room.from_user}</span>
</p>
</div>
</div>
{/* Basic room info */}
<div className="grid grid-cols-2 gap-2 mb-4">
{room.meet_id && (
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
<Hash size={12} className="text-th-accent flex-shrink-0" />
<span className="truncate font-mono" title={room.meet_id}>{room.meet_id.slice(0, 10)}</span>
</div>
)}
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
<Users size={12} className="text-th-accent flex-shrink-0" />
<span>
{t('federation.maxParticipants')}:{' '}
<span className="text-th-text font-medium">
{room.max_participants > 0 ? room.max_participants : t('federation.unlimited')}
</span>
</span>
</div>
<div className="flex items-center gap-1.5 text-xs col-span-2">
{recordingOn ? (
<>
<Video size={12} className="text-amber-500 flex-shrink-0" />
<span className="text-amber-500 font-medium">{t('federation.recordingOn')}</span>
</>
) : (
<>
<VideoOff size={12} className="text-th-text-s flex-shrink-0" />
<span className="text-th-text-s">{t('federation.recordingOff')}</span>
</>
)}
</div>
</div>
{/* Read-only notice */}
<p className="text-xs text-th-text-s mb-4 italic">{t('federation.readOnlyNotice')}</p>
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-th-border">
<button
onClick={handleJoin}
className="btn-primary text-xs py-1.5 px-3 flex-1"
>
<ExternalLink size={14} />
{t('federation.joinMeeting')}
</button>
<button
onClick={handleRemove}
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
title={t('federation.removeRoom')}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
}

View File

@@ -1,10 +1,44 @@
import { Outlet } from 'react-router-dom';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import Navbar from './Navbar';
import Sidebar from './Sidebar';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import api from '../services/api';
import toast from 'react-hot-toast';
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { user } = useAuth();
const { t } = useLanguage();
const [resendCooldown, setResendCooldown] = useState(0);
const [resending, setResending] = useState(false);
// Countdown timer for resend cooldown
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000);
return () => clearTimeout(timer);
}, [resendCooldown]);
const handleResendVerification = async () => {
if (resendCooldown > 0 || resending) return;
setResending(true);
try {
await api.post('/auth/resend-verification', { email: user.email });
toast.success(t('auth.emailVerificationResendSuccess'));
setResendCooldown(60);
} catch (err) {
const wait = err.response?.data?.waitSeconds;
if (wait) {
setResendCooldown(wait);
}
toast.error(err.response?.data?.error || t('auth.emailVerificationResendFailed'));
} finally {
setResending(false);
}
};
return (
<div className="min-h-screen bg-th-bg flex">
@@ -14,6 +48,25 @@ export default function Layout() {
{/* Main content */}
<div className="flex-1 flex flex-col min-h-screen lg:ml-64">
<Navbar onMenuClick={() => setSidebarOpen(true)} />
{/* Email verification banner */}
{user && user.email_verified === 0 && (
<div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-center gap-3 text-sm">
<AlertTriangle size={15} className="text-amber-400 flex-shrink-0" />
<span className="text-amber-200">{t('auth.emailVerificationBanner')}</span>
<button
onClick={handleResendVerification}
disabled={resendCooldown > 0 || resending}
className="flex items-center gap-1.5 text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
>
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
{resendCooldown > 0
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
: t('auth.emailVerificationResend')}
</button>
</div>
)}
<main className="flex-1 p-4 md:p-6 lg:p-8 max-w-7xl w-full mx-auto">
<Outlet />
</main>
@@ -29,3 +82,4 @@ export default function Layout() {
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { Menu, Search, LogOut, User } from 'lucide-react';
import { Menu, LogOut, User } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useNavigate } from 'react-router-dom';
@@ -27,8 +27,8 @@ export default function Navbar({ onMenuClick }) {
navigate('/');
};
const initials = user?.name
? user.name
const initials = (user?.display_name || user?.name)
? (user.display_name || user.name)
.split(' ')
.map(n => n[0])
.join('')
@@ -47,16 +47,6 @@ export default function Navbar({ onMenuClick }) {
>
<Menu size={20} />
</button>
{/* Search */}
<div className="hidden md:flex items-center gap-2 bg-th-bg-s border border-th-border rounded-lg px-3 py-2 w-64 lg:w-80">
<Search size={16} className="text-th-text-s flex-shrink-0" />
<input
type="text"
placeholder={t('common.search')}
className="bg-transparent border-none outline-none text-sm text-th-text placeholder-th-text-s w-full"
/>
</div>
</div>
{/* Right section */}
@@ -82,15 +72,15 @@ export default function Navbar({ onMenuClick }) {
)}
</div>
<span className="hidden md:block text-sm font-medium text-th-text">
{user?.name}
{user?.display_name || user?.name}
</span>
</button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-56 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
<div className="px-4 py-3 border-b border-th-border">
<p className="text-sm font-medium text-th-text">{user?.name}</p>
<p className="text-xs text-th-text-s">{user?.email}</p>
<p className="text-sm font-medium text-th-text">{user?.display_name || user?.name}</p>
<p className="text-xs text-th-text-s">@{user?.name}</p>
</div>
<div className="py-1">
<button

View File

@@ -1,4 +1,4 @@
import { Users, Play, Trash2, Radio, Loader2 } from 'lucide-react';
import { Users, Play, Trash2, Radio, Loader2, Share2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import api from '../services/api';
@@ -39,9 +39,15 @@ export default function RoomCard({ room, onDelete }) {
{t('common.live')}
</span>
)}
{room.shared ? (
<span className="flex items-center gap-1 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
<Share2 size={10} />
{t('room.shared')}
</span>
) : null}
</div>
<p className="text-sm text-th-text-s mt-0.5">
{room.uid.substring(0, 8)}...
{room.shared ? room.owner_name : `${room.uid.substring(0, 8)}...`}
</p>
</div>
</div>
@@ -93,7 +99,7 @@ export default function RoomCard({ room, onDelete }) {
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{status.running ? t('room.join') : t('room.startMeeting')}
</button>
{onDelete && (
{onDelete && !room.shared && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(room); }}
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"

View File

@@ -1,17 +1,36 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Settings, Shield, Video, X, Palette } from 'lucide-react';
import { LayoutDashboard, Settings, Shield, X, Palette, Globe } from 'lucide-react';
import BrandLogo from './BrandLogo';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import ThemeSelector from './ThemeSelector';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import api from '../services/api';
export default function Sidebar({ open, onClose }) {
const { user } = useAuth();
const { t } = useLanguage();
const [themeOpen, setThemeOpen] = useState(false);
const [federationCount, setFederationCount] = useState(0);
// Fetch pending federation invitation count
useEffect(() => {
const fetchCount = async () => {
try {
const res = await api.get('/federation/invitations/pending-count');
setFederationCount(res.data.count || 0);
} catch {
// Ignore — federation may not be enabled
}
};
fetchCount();
const interval = setInterval(fetchCount, 30000);
return () => clearInterval(interval);
}, []);
const navItems = [
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
{ to: '/federation/inbox', icon: Globe, label: t('nav.federation'), badge: federationCount },
{ to: '/settings', icon: Settings, label: t('nav.settings') },
];
@@ -20,10 +39,9 @@ export default function Sidebar({ open, onClose }) {
}
const linkClasses = ({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-th-accent text-th-accent-t shadow-sm'
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive
? 'bg-th-accent text-th-accent-t shadow-sm'
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
}`;
return (
@@ -36,14 +54,7 @@ export default function Sidebar({ open, onClose }) {
<div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
<Video size={18} className="text-white" />
</div>
<div>
<h1 className="text-lg font-bold gradient-text">Redlight</h1>
</div>
</div>
<BrandLogo size="sm" />
<button
onClick={onClose}
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
@@ -66,6 +77,11 @@ export default function Sidebar({ open, onClose }) {
>
<item.icon size={18} />
{item.label}
{item.badge > 0 && (
<span className="ml-auto px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
{item.badge}
</span>
)}
</NavLink>
))}
@@ -90,11 +106,11 @@ export default function Sidebar({ open, onClose }) {
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
>
{user?.name?.[0]?.toUpperCase() || '?'}
{(user?.display_name || user?.name)?.[0]?.toUpperCase() || '?'}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-th-text truncate">{user?.name}</p>
<p className="text-xs text-th-text-s truncate">{user?.email}</p>
<p className="text-sm font-medium text-th-text truncate">{user?.display_name || user?.name}</p>
<p className="text-xs text-th-text-s truncate">@{user?.name}</p>
</div>
</div>
</div>

View File

@@ -28,14 +28,22 @@ export function AuthProvider({ children }) {
return res.data.user;
}, []);
const register = useCallback(async (name, email, password) => {
const res = await api.post('/auth/register', { name, email, password });
const register = useCallback(async (username, displayName, email, password) => {
const res = await api.post('/auth/register', { username, display_name: displayName, email, password });
if (res.data.needsVerification) {
return { needsVerification: true };
}
localStorage.setItem('token', res.data.token);
setUser(res.data.user);
return res.data.user;
}, []);
const logout = useCallback(() => {
const logout = useCallback(async () => {
try {
await api.post('/auth/logout');
} catch {
// ignore — token is removed locally regardless
}
localStorage.removeItem('token');
setUser(null);
}, []);

View File

@@ -0,0 +1,43 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import api from '../services/api';
import { useTheme } from './ThemeContext';
const BrandingContext = createContext();
export function BrandingProvider({ children }) {
const { applyBrandingDefault } = useTheme();
const [branding, setBranding] = useState({
appName: 'Redlight',
hasLogo: false,
logoUrl: null,
defaultTheme: null,
});
const fetchBranding = useCallback(async () => {
try {
const res = await api.get('/branding');
setBranding(res.data);
if (res.data.defaultTheme) {
applyBrandingDefault(res.data.defaultTheme);
}
} catch {
// keep defaults
}
}, [applyBrandingDefault]);
useEffect(() => {
fetchBranding();
}, [fetchBranding]);
return (
<BrandingContext.Provider value={{ ...branding, refreshBranding: fetchBranding }}>
{children}
</BrandingContext.Provider>
);
}
export function useBranding() {
const ctx = useContext(BrandingContext);
if (!ctx) throw new Error('useBranding must be used within BrandingProvider');
return ctx;
}

View File

@@ -4,20 +4,33 @@ const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setThemeState] = useState(() => {
return localStorage.getItem('theme') || 'dark';
// Personal preference > last known branding default > hardcoded fallback
return localStorage.getItem('theme') || localStorage.getItem('branding-default-theme') || 'dark';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
// Called by user intentionally (Settings page etc.) — saves as personal preference
const setTheme = useCallback((newTheme) => {
localStorage.setItem('theme', newTheme);
setThemeState(newTheme);
}, []);
// Called by BrandingContext after loading the admin-configured default theme.
// Only takes effect when the user has no personal preference stored.
const applyBrandingDefault = useCallback((newDefault) => {
if (!newDefault) return;
localStorage.setItem('branding-default-theme', newDefault);
if (!localStorage.getItem('theme')) {
setThemeState(newDefault);
document.documentElement.setAttribute('data-theme', newDefault);
}
}, []);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<ThemeContext.Provider value={{ theme, setTheme, applyBrandingDefault }}>
{children}
</ThemeContext.Provider>
);

View File

@@ -31,7 +31,8 @@
"admin": "Administration",
"appearance": "Darstellung",
"changeTheme": "Theme ändern",
"navigation": "Navigation"
"navigation": "Navigation",
"federation": "Einladungen"
},
"auth": {
"login": "Anmelden",
@@ -61,7 +62,31 @@
"registerSuccess": "Registrierung erfolgreich!",
"loginFailed": "Anmeldung fehlgeschlagen",
"registerFailed": "Registrierung fehlgeschlagen",
"allFieldsRequired": "Alle Felder sind erforderlich"
"allFieldsRequired": "Alle Felder sind erforderlich",
"verificationSent": "Verifizierungs-E-Mail wurde gesendet!",
"verificationSentDesc": "Wir haben dir eine E-Mail mit einem Bestätigungslink geschickt. Bitte klicke auf den Link, um dein Konto zu aktivieren.",
"checkYourEmail": "Prüfe dein Postfach",
"verifying": "E-Mail wird verifiziert...",
"verifySuccess": "Deine E-Mail-Adresse wurde erfolgreich bestätigt. Du kannst dich jetzt anmelden.",
"verifySuccessTitle": "E-Mail bestätigt!",
"verifyFailed": "Verifizierung fehlgeschlagen",
"verifyFailedTitle": "Verifizierung fehlgeschlagen",
"verifyTokenMissing": "Kein Verifizierungstoken vorhanden.",
"emailNotVerified": "E-Mail-Adresse noch nicht verifiziert. Bitte prüfe dein Postfach.",
"username": "Benutzername",
"usernamePlaceholder": "z.B. maxmuster",
"usernameHint": "Nur Buchstaben, Zahlen, _ und - erlaubt (330 Zeichen)",
"displayName": "Anzeigename",
"displayNamePlaceholder": "Max Mustermann",
"usernameTaken": "Benutzername ist bereits vergeben",
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten (330 Zeichen)",
"usernameRequired": "Benutzername ist erforderlich",
"displayNameRequired": "Anzeigename ist erforderlich",
"emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.",
"emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten",
"emailVerificationResendCooldown": "Erneut senden in {seconds}s",
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!",
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden"
},
"home": {
"poweredBy": "Powered by BigBlueButton",
@@ -111,7 +136,10 @@
"roomDeleted": "Raum gelöscht",
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
"roomDeleteConfirm": "Raum \"{name}\" wirklich löschen?",
"loadFailed": "Räume konnten nicht geladen werden"
"loadFailed": "Räume konnten nicht geladen werden",
"sharedWithMe": "Mit mir geteilt",
"federatedRooms": "Räume von anderen Instanzen",
"federatedRoomsSubtitle": "Angenommene Meeting-Einladungen von anderen Redlight-Instanzen. Einstellungen können hier nicht geändert werden."
},
"room": {
"backToDashboard": "Zurück zum Dashboard",
@@ -182,9 +210,31 @@
"guestAccessDenied": "Zugang nicht möglich",
"guestNameRequired": "Name ist erforderlich",
"guestJoinFailed": "Beitritt fehlgeschlagen",
"guestAccessNotEnabled": "Der Gastzugang ist für diesen Raum nicht aktiviert.",
"guestWrongAccessCode": "Falscher Zugangscode",
"guestHasAccount": "Haben Sie ein Konto?",
"guestSignIn": "Anmelden",
"guestRoomNotFound": "Raum nicht gefunden"
"guestRoomNotFound": "Raum nicht gefunden",
"guestRecordingNotice": "Dieses Meeting könnte aufgenommen werden, inkl. Ihrer Audio / Video.",
"guestRecordingConsent": "Ich bin damit einverstanden, dass dieses Meeting aufgenommen werden kann.",
"shared": "Geteilt",
"presentationTitle": "Standard-Präsentation",
"presentationDesc": "Diese Datei wird beim Start des Meetings automatisch in BBB vorgeladen.",
"presentationUpload": "Präsentation hochladen",
"presentationRemove": "Präsentation entfernen",
"presentationUploaded": "Präsentation hochgeladen",
"presentationRemoved": "Präsentation entfernt",
"presentationUploadFailed": "Präsentation konnte nicht hochgeladen werden",
"presentationRemoveFailed": "Präsentation konnte nicht entfernt werden",
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
"presentationCurrent": "Aktuell:",
"shareDescription": "Teilen Sie diesen Raum mit anderen Benutzern, damit diese ihn in ihrem Dashboard sehen und beitreten k\u00f6nnen.",
"shareSearchPlaceholder": "Benutzer suchen (Name oder E-Mail)...",
"shareAdded": "Benutzer hinzugef\u00fcgt",
"shareRemoved": "Freigabe entfernt",
"shareFailed": "Freigabe fehlgeschlagen",
"shareRemove": "Freigabe entfernen",
"defaultWelcome": "Willkommen zum Meeting!"
},
"recordings": {
"title": "Aufnahmen",
@@ -266,6 +316,73 @@
"userDeleteFailed": "Fehler beim Löschen",
"passwordReset": "Passwort zurückgesetzt",
"passwordResetFailed": "Fehler beim Zurücksetzen",
"deleteUserConfirm": "Benutzer \"{name}\" wirklich löschen? Alle Räume werden ebenfalls gelöscht."
"deleteUserConfirm": "Benutzer \"{name}\" wirklich löschen? Alle Räume werden ebenfalls gelöscht.",
"brandingTitle": "Branding",
"brandingDescription": "Logo und App-Name anpassen, die in der Anwendung angezeigt werden.",
"logoLabel": "Logo",
"logoUpload": "Logo hochladen",
"logoChange": "Logo ändern",
"logoHint": "PNG, JPG, SVG oder WebP. Max. 5 MB.",
"logoUploaded": "Logo hochgeladen",
"logoUploadFailed": "Logo konnte nicht hochgeladen werden",
"logoRemoved": "Logo entfernt",
"logoRemoveFailed": "Logo konnte nicht entfernt werden",
"appNameLabel": "App-Name",
"appNameUpdated": "App-Name aktualisiert",
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden",
"defaultThemeLabel": "Standard-Theme",
"defaultThemeDesc": "Wird für nicht angemeldete Seiten (Gast-Join, Login, Startseite) verwendet, wenn keine persönliche Einstellung gesetzt ist.",
"defaultThemeSaved": "Standard-Theme gespeichert",
"defaultThemeUpdateFailed": "Standard-Theme konnte nicht aktualisiert werden"
},
"federation": {
"inbox": "Einladungen",
"inboxSubtitle": "Meeting-Einladungen von anderen Redlight-Instanzen",
"inviteTitle": "Remote-Benutzer einladen",
"inviteSubtitle": "Einen Benutzer von einer anderen Redlight-Instanz zu diesem Meeting einladen.",
"addressLabel": "Benutzeradresse",
"addressPlaceholder": "@benutzer@andere-instanz.com",
"addressHint": "Format: @Benutzername@Domain der Redlight-Instanz",
"messageLabel": "Nachricht (optional)",
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
"send": "Einladung senden",
"sent": "Einladung gesendet!",
"sendFailed": "Einladung konnte nicht gesendet werden",
"from": "Von",
"accept": "Annehmen",
"decline": "Ablehnen",
"accepted": "Einladung angenommen",
"declined": "Einladung abgelehnt",
"acceptFailed": "Fehler beim Annehmen",
"declineFailed": "Fehler beim Ablehnen",
"pending": "Ausstehend",
"previousInvites": "Frühere Einladungen",
"noInvitations": "Keine Einladungen",
"noInvitationsSubtitle": "Wenn Sie von einer anderen Redlight-Instanz eingeladen werden, erscheint die Einladung hier.",
"statusAccepted": "Angenommen",
"statusDeclined": "Abgelehnt",
"openLink": "Meeting öffnen",
"loadFailed": "Einladungen konnten nicht geladen werden",
"inviteRemote": "Remote einladen",
"federated": "Fremd-Instanz",
"readOnlyNotice": "Dieser Raum gehört einer anderen Instanz. Einstellungen können nicht geändert werden.",
"joinMeeting": "Meeting beitreten",
"removeRoom": "Raum entfernen",
"removeRoomConfirm": "Raum wirklich entfernen?",
"roomRemoved": "Raum entfernt",
"roomRemoveFailed": "Raum konnte nicht entfernt werden",
"acceptedSaved": "Einladung angenommen Raum wurde in deinem Dashboard gespeichert!",
"meetingId": "Meeting ID",
"maxParticipants": "Max. Teilnehmer",
"recordingOn": "Aufnahme aktiviert",
"recordingOff": "Aufnahme deaktiviert",
"unlimited": "Unbegrenzt",
"backToDashboard": "Zurück zum Dashboard",
"participantLimit": "Teilnehmerlimit gesetzt",
"recordingLabel": "Aufnahme",
"recordingOnHint": "Meetings in diesem Raum können aufgezeichnet werden",
"recordingOffHint": "Meetings in diesem Raum werden nicht aufgezeichnet",
"roomDetails": "Raumdetails",
"joinUrl": "Beitritts-URL"
}
}
}

View File

@@ -31,7 +31,8 @@
"admin": "Administration",
"appearance": "Appearance",
"changeTheme": "Change theme",
"navigation": "Navigation"
"navigation": "Navigation",
"federation": "Invitations"
},
"auth": {
"login": "Sign in",
@@ -61,7 +62,31 @@
"registerSuccess": "Registration successful!",
"loginFailed": "Login failed",
"registerFailed": "Registration failed",
"allFieldsRequired": "All fields are required"
"allFieldsRequired": "All fields are required",
"verificationSent": "Verification email sent!",
"verificationSentDesc": "We've sent you an email with a verification link. Please click the link to activate your account.",
"checkYourEmail": "Check your inbox",
"verifying": "Verifying your email...",
"verifySuccess": "Your email has been verified successfully. You can now sign in.",
"verifySuccessTitle": "Email verified!",
"verifyFailed": "Verification failed",
"verifyFailedTitle": "Verification failed",
"verifyTokenMissing": "No verification token provided.",
"emailNotVerified": "Email not yet verified. Please check your inbox.",
"username": "Username",
"usernamePlaceholder": "e.g. johndoe",
"usernameHint": "Letters, numbers, _ and - only (330 chars)",
"displayName": "Display Name",
"displayNamePlaceholder": "John Doe",
"usernameTaken": "Username is already taken",
"usernameInvalid": "Username may only contain letters, numbers, _ and - (330 chars)",
"usernameRequired": "Username is required",
"displayNameRequired": "Display name is required",
"emailVerificationBanner": "Your email address has not been verified yet.",
"emailVerificationResend": "Click here to receive a new verification email",
"emailVerificationResendCooldown": "Resend in {seconds}s",
"emailVerificationResendSuccess": "Verification email sent!",
"emailVerificationResendFailed": "Could not send verification email"
},
"home": {
"poweredBy": "Powered by BigBlueButton",
@@ -111,7 +136,10 @@
"roomDeleted": "Room deleted",
"roomDeleteFailed": "Room could not be deleted",
"roomDeleteConfirm": "Really delete room \"{name}\"?",
"loadFailed": "Rooms could not be loaded"
"loadFailed": "Rooms could not be loaded",
"sharedWithMe": "Shared with me",
"federatedRooms": "Rooms from other instances",
"federatedRoomsSubtitle": "Accepted meeting invitations from other Redlight instances. Settings cannot be changed here."
},
"room": {
"backToDashboard": "Back to Dashboard",
@@ -182,9 +210,31 @@
"guestAccessDenied": "Access denied",
"guestNameRequired": "Name is required",
"guestJoinFailed": "Join failed",
"guestAccessNotEnabled": "Guest access is not enabled for this room.",
"guestWrongAccessCode": "Wrong access code",
"guestHasAccount": "Have an account?",
"guestSignIn": "Sign in",
"guestRoomNotFound": "Room not found"
"guestRoomNotFound": "Room not found",
"guestRecordingNotice": "This meeting may be recorded, including your audio and video.",
"guestRecordingConsent": "I understand that this meeting may be recorded.",
"shared": "Shared",
"presentationTitle": "Default Presentation",
"presentationDesc": "This file will be automatically pre-loaded in BBB when the meeting starts.",
"presentationUpload": "Upload presentation",
"presentationRemove": "Remove presentation",
"presentationUploaded": "Presentation uploaded",
"presentationRemoved": "Presentation removed",
"presentationUploadFailed": "Could not upload presentation",
"presentationRemoveFailed": "Could not remove presentation",
"presentationAllowedTypes": "PDF, PPT, PPTX, ODP, DOC, DOCX · max. 50 MB",
"presentationCurrent": "Current:",
"shareDescription": "Share this room with other users so they can see it in their dashboard and join meetings.",
"shareSearchPlaceholder": "Search users (name or email)...",
"shareAdded": "User added",
"shareRemoved": "Share removed",
"shareFailed": "Share failed",
"shareRemove": "Remove share",
"defaultWelcome": "Welcome to the meeting!"
},
"recordings": {
"title": "Recordings",
@@ -266,6 +316,73 @@
"userDeleteFailed": "Error deleting user",
"passwordReset": "Password reset",
"passwordResetFailed": "Error resetting password",
"deleteUserConfirm": "Really delete user \"{name}\"? All rooms will also be deleted."
"deleteUserConfirm": "Really delete user \"{name}\"? All rooms will also be deleted.",
"brandingTitle": "Branding",
"brandingDescription": "Customize the logo and app name shown across the application.",
"logoLabel": "Logo",
"logoUpload": "Upload logo",
"logoChange": "Change logo",
"logoHint": "PNG, JPG, SVG or WebP. Max 5 MB.",
"logoUploaded": "Logo uploaded",
"logoUploadFailed": "Logo upload failed",
"logoRemoved": "Logo removed",
"logoRemoveFailed": "Could not remove logo",
"appNameLabel": "App name",
"appNameUpdated": "App name updated",
"appNameUpdateFailed": "Could not update app name",
"defaultThemeLabel": "Default Theme",
"defaultThemeDesc": "Applied to unauthenticated pages (guest join, login, home) when no personal preference is set.",
"defaultThemeSaved": "Default theme saved",
"defaultThemeUpdateFailed": "Could not update default theme"
},
"federation": {
"inbox": "Invitations",
"inboxSubtitle": "Meeting invitations from other Redlight instances",
"inviteTitle": "Invite Remote User",
"inviteSubtitle": "Invite a user from another Redlight instance to this meeting.",
"addressLabel": "User address",
"addressPlaceholder": "@user@other-instance.com",
"addressHint": "Format: @username@domain of the Redlight instance",
"messageLabel": "Message (optional)",
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
"send": "Send invitation",
"sent": "Invitation sent!",
"sendFailed": "Could not send invitation",
"from": "From",
"accept": "Accept",
"decline": "Decline",
"accepted": "Invitation accepted",
"declined": "Invitation declined",
"acceptFailed": "Error accepting invitation",
"declineFailed": "Error declining invitation",
"pending": "Pending",
"previousInvites": "Previous Invitations",
"noInvitations": "No invitations",
"noInvitationsSubtitle": "When you receive an invitation from another Redlight instance, it will appear here.",
"statusAccepted": "Accepted",
"statusDeclined": "Declined",
"openLink": "Open meeting",
"loadFailed": "Could not load invitations",
"inviteRemote": "Invite remote",
"federated": "Federated",
"readOnlyNotice": "This room belongs to another instance. Settings cannot be changed.",
"joinMeeting": "Join meeting",
"removeRoom": "Remove room",
"removeRoomConfirm": "Really remove this room?",
"roomRemoved": "Room removed",
"roomRemoveFailed": "Could not remove room",
"acceptedSaved": "Invitation accepted room saved to your dashboard!",
"meetingId": "Meeting ID",
"maxParticipants": "Max. participants",
"recordingOn": "Recording enabled",
"recordingOff": "Recording disabled",
"unlimited": "Unlimited",
"backToDashboard": "Back to Dashboard",
"participantLimit": "Participant limit set",
"recordingLabel": "Recording",
"recordingOnHint": "Meetings in this room may be recorded",
"recordingOffHint": "Meetings in this room will not be recorded",
"roomDetails": "Room Details",
"joinUrl": "Join URL"
}
}
}

View File

@@ -412,7 +412,34 @@
background-color: var(--border);
border-radius: 3px;
}
}
}
/* ===== SCRUNKLY.CAT DARK ===== */
[data-theme="scrunkly-cat"] {
--bg-primary: #161924;
--bg-secondary: #161924;
--bg-tertiary: #1b2130;
--text-primary: #dadada;
--text-secondary: #94a3b8;
--accent: #b30051;
--accent-hover: #d6336a;
--accent-text: #ffffff;
--border: rgba(255, 255, 255, 0.1);
--card-bg: #1b2130;
--input-bg: rgba(255, 255, 255, 0.05);
--input-border: rgba(255, 255, 255, 0.1);
--nav-bg: #1b2130;
--sidebar-bg: #161924;
--hover-bg: rgba(255, 255, 255, 0.03);
--success: #86b300;
--warning: #ecb637;
--error: #ec4137;
--ring: #b30051;
--shadow-color: rgba(0, 0, 0, 0.3);
--gradient-start: #b30051;
--gradient-end: #d6336a;
}
@layer components {
.btn-primary {

View File

@@ -6,6 +6,7 @@ import App from './App';
import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { BrandingProvider } from './contexts/BrandingContext';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
@@ -13,21 +14,23 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<BrowserRouter>
<LanguageProvider>
<ThemeProvider>
<AuthProvider>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'var(--card-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
},
}}
/>
</AuthProvider>
</ThemeProvider>
<BrandingProvider>
<AuthProvider>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'var(--card-bg)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
},
}}
/>
</AuthProvider>
</BrandingProvider>
</ThemeProvider>
</LanguageProvider>
</BrowserRouter>
</React.StrictMode>,

View File

@@ -1,17 +1,21 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Users, Shield, Search, Trash2, ChevronDown, Loader2,
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
Upload, X as XIcon, Image, Type, Palette,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
import { themes } from '../themes';
import api from '../services/api';
import toast from 'react-hot-toast';
export default function Admin() {
const { user } = useAuth();
const { t, language } = useLanguage();
const { appName, hasLogo, logoUrl, defaultTheme, refreshBranding } = useBranding();
const navigate = useNavigate();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -21,7 +25,15 @@ export default function Admin() {
const [newPassword, setNewPassword] = useState('');
const [showCreateUser, setShowCreateUser] = useState(false);
const [creatingUser, setCreatingUser] = useState(false);
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
const [newUser, setNewUser] = useState({ name: '', display_name: '', email: '', password: '', role: 'user' });
// Branding state
const [editAppName, setEditAppName] = useState('');
const [savingName, setSavingName] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const logoInputRef = useRef(null);
const [editDefaultTheme, setEditDefaultTheme] = useState('');
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
useEffect(() => {
if (user?.role !== 'admin') {
@@ -31,6 +43,14 @@ export default function Admin() {
fetchUsers();
}, [user]);
useEffect(() => {
setEditAppName(appName || 'Redlight');
}, [appName]);
useEffect(() => {
setEditDefaultTheme(defaultTheme || 'dark');
}, [defaultTheme]);
const fetchUsers = async () => {
try {
const res = await api.get('/admin/users');
@@ -77,6 +97,65 @@ export default function Admin() {
}
};
// ── Branding handlers ──────────────────────────────────────────────────
const handleLogoUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingLogo(true);
try {
const formData = new FormData();
formData.append('logo', file);
await api.post('/branding/logo', formData, {
headers: { 'Content-Type': undefined },
});
toast.success(t('admin.logoUploaded'));
refreshBranding();
} catch (err) {
toast.error(err.response?.data?.error || t('admin.logoUploadFailed'));
} finally {
setUploadingLogo(false);
if (logoInputRef.current) logoInputRef.current.value = '';
}
};
const handleLogoRemove = async () => {
try {
await api.delete('/branding/logo');
toast.success(t('admin.logoRemoved'));
refreshBranding();
} catch {
toast.error(t('admin.logoRemoveFailed'));
}
};
const handleAppNameSave = async () => {
if (!editAppName.trim()) return;
setSavingName(true);
try {
await api.put('/branding/name', { appName: editAppName.trim() });
toast.success(t('admin.appNameUpdated'));
refreshBranding();
} catch {
toast.error(t('admin.appNameUpdateFailed'));
} finally {
setSavingName(false);
}
};
const handleDefaultThemeSave = async () => {
if (!editDefaultTheme) return;
setSavingDefaultTheme(true);
try {
await api.put('/branding/default-theme', { defaultTheme: editDefaultTheme });
toast.success(t('admin.defaultThemeSaved'));
refreshBranding();
} catch {
toast.error(t('admin.defaultThemeUpdateFailed'));
} finally {
setSavingDefaultTheme(false);
}
};
const handleCreateUser = async (e) => {
e.preventDefault();
setCreatingUser(true);
@@ -84,7 +163,7 @@ export default function Admin() {
await api.post('/admin/users', newUser);
toast.success(t('admin.userCreated'));
setShowCreateUser(false);
setNewUser({ name: '', email: '', password: '', role: 'user' });
setNewUser({ name: '', display_name: '', email: '', password: '', role: 'user' });
fetchUsers();
} catch (err) {
toast.error(err.response?.data?.error || t('admin.userCreateFailed'));
@@ -94,7 +173,7 @@ export default function Admin() {
};
const filteredUsers = users.filter(u =>
u.name.toLowerCase().includes(search.toLowerCase()) ||
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
);
@@ -126,6 +205,119 @@ export default function Admin() {
</div>
</div>
{/* Branding */}
<div className="card p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<Image size={20} className="text-th-accent" />
<h2 className="text-lg font-semibold text-th-text">{t('admin.brandingTitle')}</h2>
</div>
<p className="text-sm text-th-text-s mb-5">{t('admin.brandingDescription')}</p>
<div className="grid gap-6 sm:grid-cols-2">
{/* Logo upload */}
<div>
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</label>
<div className="flex items-center gap-4">
{hasLogo && logoUrl ? (
<div className="relative group">
<img
src={`${logoUrl}?t=${Date.now()}`}
alt="Logo"
className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1"
/>
<button
onClick={handleLogoRemove}
className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<XIcon size={12} />
</button>
</div>
) : (
<div className="w-14 h-14 rounded-xl border-2 border-dashed border-th-border flex items-center justify-center text-th-text-s">
<Image size={24} />
</div>
)}
<div>
<input
ref={logoInputRef}
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<button
onClick={() => logoInputRef.current?.click()}
disabled={uploadingLogo}
className="btn-secondary text-sm"
>
{uploadingLogo ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Upload size={14} />
)}
{hasLogo ? t('admin.logoChange') : t('admin.logoUpload')}
</button>
<p className="text-xs text-th-text-s mt-1">{t('admin.logoHint')}</p>
</div>
</div>
</div>
{/* App name */}
<div>
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={editAppName}
onChange={e => setEditAppName(e.target.value)}
className="input-field pl-9 text-sm"
placeholder="Redlight"
maxLength={30}
/>
</div>
<button
onClick={handleAppNameSave}
disabled={savingName || editAppName.trim() === appName}
className="btn-primary text-sm px-4"
>
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</div>
</div>
</div>
{/* Default theme */}
<div className="mt-6 pt-6 border-t border-th-border">
<div className="flex items-center gap-2 mb-1">
<Palette size={16} className="text-th-accent" />
<label className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label>
</div>
<p className="text-xs text-th-text-s mb-3">{t('admin.defaultThemeDesc')}</p>
<div className="flex items-center gap-2">
<select
value={editDefaultTheme}
onChange={e => setEditDefaultTheme(e.target.value)}
className="input-field text-sm flex-1"
>
{themes.map(th => (
<option key={th.id} value={th.id}>
{th.name} ({th.type === 'light' ? t('themes.light') : t('themes.dark')})
</option>
))}
</select>
<button
onClick={handleDefaultThemeSave}
disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')}
className="btn-primary text-sm px-4 flex-shrink-0"
>
{savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
</div>
</div>
</div>
{/* Search */}
<div className="card p-4 mb-6">
<div className="relative">
@@ -179,12 +371,12 @@ export default function Admin() {
className="w-full h-full object-cover"
/>
) : (
u.name[0]?.toUpperCase()
(u.display_name || u.name)[0]?.toUpperCase()
)}
</div>
<div>
<p className="text-sm font-medium text-th-text">{u.name}</p>
<p className="text-xs text-th-text-s">{u.email}</p>
<p className="text-sm font-medium text-th-text">{u.display_name || u.name}</p>
<p className="text-xs text-th-text-s">@{u.name} · {u.email}</p>
</div>
</div>
</td>
@@ -298,7 +490,7 @@ export default function Admin() {
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
<form onSubmit={handleCreateUser} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
@@ -306,10 +498,24 @@ export default function Admin() {
value={newUser.name}
onChange={e => setNewUser({ ...newUser, name: e.target.value })}
className="input-field pl-11"
placeholder={t('auth.namePlaceholder')}
placeholder={t('auth.usernamePlaceholder')}
required
/>
</div>
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={newUser.display_name}
onChange={e => setNewUser({ ...newUser, display_name: e.target.value })}
className="input-field pl-11"
placeholder={t('auth.displayNamePlaceholder')}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>

View File

@@ -3,12 +3,14 @@ import { Plus, Video, Loader2, LayoutGrid, List } from 'lucide-react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import RoomCard from '../components/RoomCard';
import FederatedRoomCard from '../components/FederatedRoomCard';
import Modal from '../components/Modal';
import toast from 'react-hot-toast';
export default function Dashboard() {
const { t } = useLanguage();
const [rooms, setRooms] = useState([]);
const [federatedRooms, setFederatedRooms] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [viewMode, setViewMode] = useState('grid');
@@ -33,8 +35,18 @@ export default function Dashboard() {
}
};
const fetchFederatedRooms = async () => {
try {
const res = await api.get('/federation/federated-rooms');
setFederatedRooms(res.data.rooms || []);
} catch {
// Federation may not be enabled
}
};
useEffect(() => {
fetchRooms();
fetchFederatedRooms();
}, []);
const handleCreate = async (e) => {
@@ -118,7 +130,7 @@ export default function Dashboard() {
</div>
{/* Room grid/list */}
{rooms.length === 0 ? (
{rooms.length === 0 && federatedRooms.length === 0 ? (
<div className="card p-12 text-center">
<Video size={48} className="mx-auto text-th-text-s/40 mb-4" />
<h3 className="text-lg font-semibold text-th-text mb-2">{t('dashboard.noRooms')}</h3>
@@ -131,15 +143,53 @@ export default function Dashboard() {
</button>
</div>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
: 'space-y-3'
}>
{rooms.map(room => (
<RoomCard key={room.id} room={room} onDelete={handleDelete} />
))}
</div>
<>
{/* Own rooms */}
{rooms.filter(r => !r.shared).length > 0 && (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
: 'space-y-3'
}>
{rooms.filter(r => !r.shared).map(room => (
<RoomCard key={room.id} room={room} onDelete={handleDelete} />
))}
</div>
)}
{/* Shared rooms */}
{rooms.filter(r => r.shared).length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold text-th-text mb-4">{t('dashboard.sharedWithMe')}</h2>
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
: 'space-y-3'
}>
{rooms.filter(r => r.shared).map(room => (
<RoomCard key={`shared-${room.id}`} room={room} onDelete={handleDelete} />
))}
</div>
</div>
)}
{/* Federated rooms (from other instances) */}
{federatedRooms.length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold text-th-text mb-1">{t('dashboard.federatedRooms')}</h2>
<p className="text-sm text-th-text-s mb-4">{t('dashboard.federatedRoomsSubtitle')}</p>
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
: 'space-y-3'
}>
{federatedRooms.map(room => (
<FederatedRoomCard key={room.id} room={room} onRemove={fetchFederatedRooms} />
))}
</div>
</div>
)}
</>
)}
{/* Create Room Modal */}

View File

@@ -0,0 +1,183 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft, Globe, ExternalLink, Trash2, Hash, Users,
Video, VideoOff, Loader2, Link2,
} from 'lucide-react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
export default function FederatedRoomDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { t } = useLanguage();
const [room, setRoom] = useState(null);
const [loading, setLoading] = useState(true);
const [removing, setRemoving] = useState(false);
useEffect(() => {
const fetch = async () => {
try {
const res = await api.get('/federation/federated-rooms');
const found = (res.data.rooms || []).find(r => String(r.id) === String(id));
if (!found) {
toast.error(t('room.notFound'));
navigate('/dashboard');
return;
}
setRoom(found);
} catch {
toast.error(t('room.notFound'));
navigate('/dashboard');
} finally {
setLoading(false);
}
};
fetch();
}, [id]);
const handleJoin = () => {
window.open(room.join_url, '_blank');
};
const handleRemove = async () => {
if (!confirm(t('federation.removeRoomConfirm'))) return;
setRemoving(true);
try {
await api.delete(`/federation/federated-rooms/${room.id}`);
toast.success(t('federation.roomRemoved'));
navigate('/dashboard');
} catch {
toast.error(t('federation.roomRemoveFailed'));
setRemoving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 size={32} className="animate-spin text-th-accent" />
</div>
);
}
if (!room) return null;
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
return (
<div className="max-w-3xl mx-auto">
{/* Back */}
<button
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 text-sm text-th-text-s hover:text-th-text transition-colors mb-6"
>
<ArrowLeft size={16} />
{t('federation.backToDashboard')}
</button>
{/* Header */}
<div className="card p-6 mb-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<div className="w-10 h-10 rounded-lg bg-th-accent/15 flex items-center justify-center flex-shrink-0 mt-0.5">
<Globe size={20} className="text-th-accent" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-bold text-th-text truncate">{room.room_name}</h1>
<span className="px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
{t('federation.federated')}
</span>
</div>
<p className="text-sm text-th-text-s mt-1">
{t('federation.from')}: <span className="font-medium text-th-text">{room.from_user}</span>
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={handleJoin}
className="btn-primary"
>
<ExternalLink size={16} />
{t('federation.joinMeeting')}
</button>
<button
onClick={handleRemove}
disabled={removing}
className="btn-ghost text-th-error hover:text-th-error px-3"
title={t('federation.removeRoom')}
>
{removing ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
</button>
</div>
</div>
</div>
{/* Info cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
{/* Max participants */}
<div className="card p-5">
<div className="flex items-center gap-2 mb-1 text-xs font-semibold text-th-text-s uppercase tracking-wider">
<Users size={13} />
{t('federation.maxParticipants')}
</div>
<p className="text-2xl font-bold text-th-text">
{room.max_participants > 0 ? room.max_participants : '∞'}
</p>
<p className="text-xs text-th-text-s mt-0.5">
{room.max_participants > 0 ? t('federation.participantLimit') : t('federation.unlimited')}
</p>
</div>
{/* Recording */}
<div className="card p-5">
<div className="flex items-center gap-2 mb-1 text-xs font-semibold text-th-text-s uppercase tracking-wider">
{recordingOn ? <Video size={13} /> : <VideoOff size={13} />}
{t('federation.recordingLabel')}
</div>
<p className={`text-lg font-bold ${recordingOn ? 'text-amber-400' : 'text-th-text-s'}`}>
{recordingOn ? t('federation.recordingOn') : t('federation.recordingOff')}
</p>
<p className="text-xs text-th-text-s mt-0.5">
{recordingOn ? t('federation.recordingOnHint') : t('federation.recordingOffHint')}
</p>
</div>
</div>
{/* Details */}
<div className="card p-5 space-y-4">
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider">
{t('federation.roomDetails')}
</h2>
{room.meet_id && (
<div className="flex items-start gap-3">
<Hash size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p>
<p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p>
</div>
</div>
)}
<div className="flex items-start gap-3">
<Link2 size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p>
<p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p>
</div>
</div>
</div>
{/* Read-only notice */}
<p className="text-xs text-th-text-s mt-4 text-center italic">
{t('federation.readOnlyNotice')}
</p>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { useState, useEffect } from 'react';
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox } from 'lucide-react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
export default function FederationInbox() {
const { t } = useLanguage();
const [invitations, setInvitations] = useState([]);
const [loading, setLoading] = useState(true);
const fetchInvitations = async () => {
try {
const res = await api.get('/federation/invitations');
setInvitations(res.data.invitations || []);
} catch {
toast.error(t('federation.loadFailed'));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchInvitations();
}, []);
const handleAccept = async (id) => {
try {
await api.post(`/federation/invitations/${id}/accept`);
toast.success(t('federation.acceptedSaved'));
fetchInvitations();
} catch {
toast.error(t('federation.acceptFailed'));
}
};
const handleDecline = async (id) => {
try {
await api.delete(`/federation/invitations/${id}`);
toast.success(t('federation.declined'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 size={32} className="animate-spin text-th-accent" />
</div>
);
}
const pending = invitations.filter(i => i.status === 'pending');
const past = invitations.filter(i => i.status !== 'pending');
return (
<div>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-1">
<Globe size={24} className="text-th-accent" />
<h1 className="text-2xl font-bold text-th-text">{t('federation.inbox')}</h1>
</div>
<p className="text-sm text-th-text-s mt-1">{t('federation.inboxSubtitle')}</p>
</div>
{/* Pending invitations */}
{pending.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
{t('federation.pending')} ({pending.length})
</h2>
<div className="space-y-3">
{pending.map(inv => (
<div key={inv.id} className="card p-5 border-l-4 border-l-th-accent">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Mail size={16} className="text-th-accent flex-shrink-0" />
<h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3>
</div>
<p className="text-sm text-th-text-s">
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
</p>
{inv.message && (
<p className="text-sm text-th-text-s mt-2 italic">"{inv.message}"</p>
)}
<p className="text-xs text-th-text-s mt-1">
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleAccept(inv.id)}
className="btn-primary text-sm"
>
<Check size={16} />
{t('federation.accept')}
</button>
<button
onClick={() => handleDecline(inv.id)}
className="btn-secondary text-sm"
>
<X size={16} />
{t('federation.decline')}
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Past invitations */}
{past.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
{t('federation.previousInvites')}
</h2>
<div className="space-y-2">
{past.map(inv => (
<div key={inv.id} className="card p-4 opacity-60">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
<p className="text-xs text-th-text-s">{inv.from_user}</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
</span>
{inv.status === 'accepted' && (
<button
onClick={() => window.open(inv.join_url, '_blank')}
className="btn-ghost text-xs py-1.5 px-2"
title={t('federation.openLink')}
>
<ExternalLink size={14} />
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty state */}
{invitations.length === 0 && (
<div className="card p-12 text-center">
<Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" />
<h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3>
<p className="text-sm text-th-text-s">{t('federation.noInvitationsSubtitle')}</p>
</div>
)}
</div>
);
}

View File

@@ -1,21 +1,26 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react';
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
import { useLanguage } from '../contexts/LanguageContext';
import { useAuth } from '../contexts/AuthContext';
export default function GuestJoin() {
const { uid } = useParams();
const { t } = useLanguage();
const { user } = useAuth();
const isLoggedIn = !!user;
const [roomInfo, setRoomInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [joining, setJoining] = useState(false);
const [name, setName] = useState('');
const [name, setName] = useState(user?.name || '');
const [accessCode, setAccessCode] = useState('');
const [moderatorCode, setModeratorCode] = useState('');
const [status, setStatus] = useState({ running: false });
const [recordingConsent, setRecordingConsent] = useState(false);
useEffect(() => {
const fetchRoom = async () => {
@@ -24,7 +29,14 @@ export default function GuestJoin() {
setRoomInfo(res.data.room);
setStatus({ running: res.data.running });
} catch (err) {
setError(err.response?.data?.error || t('room.guestRoomNotFound'));
const status = err.response?.status;
if (status === 403) {
setError(t('room.guestAccessNotEnabled'));
} else if (status === 404) {
setError(t('room.guestRoomNotFound'));
} else {
setError(t('room.guestRoomNotFound'));
}
} finally {
setLoading(false);
}
@@ -50,6 +62,11 @@ export default function GuestJoin() {
return;
}
if (roomInfo?.allow_recording && !recordingConsent) {
toast.error(t('room.guestRecordingConsent'));
return;
}
setJoining(true);
try {
const res = await api.post(`/rooms/${uid}/guest-join`, {
@@ -61,7 +78,14 @@ export default function GuestJoin() {
window.location.href = res.data.joinUrl;
}
} catch (err) {
toast.error(err.response?.data?.error || t('room.guestJoinFailed'));
const status = err.response?.status;
if (status === 403) {
toast.error(t('room.guestWrongAccessCode'));
} else if (status === 400) {
toast.error(t('room.guestWaitingMessage'));
} else {
toast.error(t('room.guestJoinFailed'));
}
} finally {
setJoining(false);
}
@@ -115,11 +139,8 @@ export default function GuestJoin() {
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
{/* Logo */}
<div className="flex items-center justify-center gap-2.5 mb-6">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<Video size={22} className="text-white" />
</div>
<span className="text-2xl font-bold gradient-text">Redlight</span>
<div className="flex justify-center mb-6">
<BrandLogo size="lg" />
</div>
{/* Room info */}
@@ -148,11 +169,12 @@ export default function GuestJoin() {
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="input-field pl-11"
onChange={e => !isLoggedIn && setName(e.target.value)}
readOnly={isLoggedIn}
className={`input-field pl-11 ${isLoggedIn ? 'opacity-70 cursor-not-allowed' : ''}`}
placeholder={t('room.guestNamePlaceholder')}
required
autoFocus
autoFocus={!isLoggedIn}
/>
</div>
</div>
@@ -190,9 +212,28 @@ export default function GuestJoin() {
</div>
</div>
{/* Recording consent notice */}
{roomInfo.allow_recording && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={recordingConsent}
onChange={e => setRecordingConsent(e.target.checked)}
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
/>
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
</label>
</div>
)}
<button
type="submit"
disabled={joining || (!status.running && !roomInfo.anyone_can_start)}
disabled={joining || (!status.running && !roomInfo.anyone_can_start) || (roomInfo.allow_recording && !recordingConsent)}
className="btn-primary w-full py-3"
>
{joining ? (
@@ -212,11 +253,13 @@ export default function GuestJoin() {
)}
</form>
<div className="mt-6 pt-4 border-t border-th-border text-center">
<Link to="/login" className="text-sm text-th-text-s hover:text-th-accent transition-colors">
{t('room.guestHasAccount')} <span className="text-th-accent font-medium">{t('room.guestSignIn')}</span>
</Link>
</div>
{!isLoggedIn && (
<div className="mt-6 pt-4 border-t border-th-border text-center">
<Link to="/login" className="text-sm text-th-text-s hover:text-th-accent transition-colors">
{t('room.guestHasAccount')} <span className="text-th-accent font-medium">{t('room.guestSignIn')}</span>
</Link>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import { useLanguage } from '../contexts/LanguageContext';
export default function Home() {
@@ -48,12 +49,7 @@ export default function Home() {
{/* Navbar */}
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 gradient-bg rounded-lg flex items-center justify-center">
<Video size={20} className="text-white" />
</div>
<span className="text-xl font-bold gradient-text">Redlight</span>
</div>
<BrandLogo size="md" />
<div className="flex items-center gap-3">
<Link to="/login" className="btn-ghost text-sm">
{t('auth.login')}

View File

@@ -1,18 +1,47 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Video, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
import { Mail, Lock, ArrowRight, Loader2, AlertTriangle, RefreshCw } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import toast from 'react-hot-toast';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [needsVerification, setNeedsVerification] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const [resending, setResending] = useState(false);
const { login } = useAuth();
const { t } = useLanguage();
const navigate = useNavigate();
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000);
return () => clearTimeout(timer);
}, [resendCooldown]);
const handleResend = async () => {
if (resendCooldown > 0 || resending) return;
setResending(true);
try {
await api.post('/auth/resend-verification', { email });
toast.success(t('auth.emailVerificationResendSuccess'));
setResendCooldown(60);
} catch (err) {
const wait = err.response?.data?.waitSeconds;
if (wait) {
setResendCooldown(wait);
}
toast.error(err.response?.data?.error || t('auth.emailVerificationResendFailed'));
} finally {
setResending(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
@@ -21,7 +50,11 @@ export default function Login() {
toast.success(t('auth.loginSuccess'));
navigate('/dashboard');
} catch (err) {
toast.error(err.response?.data?.error || t('auth.loginFailed'));
if (err.response?.data?.needsVerification) {
setNeedsVerification(true);
} else {
toast.error(err.response?.data?.error || t('auth.loginFailed'));
}
} finally {
setLoading(false);
}
@@ -42,11 +75,8 @@ export default function Login() {
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
{/* Logo */}
<div className="flex items-center justify-center gap-2.5 mb-8">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<Video size={22} className="text-white" />
</div>
<span className="text-2xl font-bold gradient-text">Redlight</span>
<div className="flex justify-center mb-8">
<BrandLogo size="lg" />
</div>
<div className="mb-8">
@@ -103,6 +133,25 @@ export default function Login() {
</button>
</form>
{needsVerification && (
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
<div className="flex items-start gap-2">
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
</div>
<button
onClick={handleResend}
disabled={resendCooldown > 0 || resending}
className="flex items-center gap-1.5 text-sm text-amber-400 hover:text-amber-300 underline underline-offset-2 transition-colors disabled:opacity-60 disabled:no-underline disabled:cursor-not-allowed"
>
<RefreshCw size={13} className={resending ? 'animate-spin' : ''} />
{resendCooldown > 0
? t('auth.emailVerificationResendCooldown').replace('{seconds}', resendCooldown)
: t('auth.emailVerificationResend')}
</button>
</div>
)}
<p className="mt-6 text-center text-sm text-th-text-s">
{t('auth.noAccount')}{' '}
<Link to="/register" className="text-th-accent hover:underline font-medium">

View File

@@ -2,15 +2,18 @@ import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { Video, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
import { Mail, Lock, User, ArrowRight, Loader2, CheckCircle } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import toast from 'react-hot-toast';
export default function Register() {
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [needsVerification, setNeedsVerification] = useState(false);
const { register } = useAuth();
const { t } = useLanguage();
const navigate = useNavigate();
@@ -30,9 +33,14 @@ export default function Register() {
setLoading(true);
try {
await register(name, email, password);
toast.success(t('auth.registerSuccess'));
navigate('/dashboard');
const result = await register(username, displayName, email, password);
if (result?.needsVerification) {
setNeedsVerification(true);
toast.success(t('auth.verificationSent'));
} else {
toast.success(t('auth.registerSuccess'));
navigate('/dashboard');
}
} catch (err) {
toast.error(err.response?.data?.error || t('auth.registerFailed'));
} finally {
@@ -55,13 +63,22 @@ export default function Register() {
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
{/* Logo */}
<div className="flex items-center justify-center gap-2.5 mb-8">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<Video size={22} className="text-white" />
</div>
<span className="text-2xl font-bold gradient-text">Redlight</span>
<div className="flex justify-center mb-8">
<BrandLogo size="lg" />
</div>
{needsVerification ? (
<div className="text-center space-y-4">
<CheckCircle size={48} className="mx-auto text-green-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.checkYourEmail')}</h2>
<p className="text-th-text-s">{t('auth.verificationSentDesc')}</p>
<p className="text-sm text-th-text-s font-medium">{email}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
) : (
<>
<div className="mb-8">
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.createAccount')}</h2>
<p className="text-th-text-s">
@@ -71,15 +88,31 @@ export default function Register() {
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
value={username}
onChange={e => setUsername(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.namePlaceholder')}
placeholder={t('auth.usernamePlaceholder')}
required
/>
</div>
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
className="input-field pl-11"
placeholder={t('auth.displayNamePlaceholder')}
required
/>
</div>
@@ -158,6 +191,8 @@ export default function Register() {
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link>
</>
)}
</div>
</div>
</div>

View File

@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
Shield, Save,
Shield, Save, UserPlus, X, Share2, Globe, Send,
FileText, Upload, Trash2,
} from 'lucide-react';
import Modal from '../components/Modal';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -25,14 +27,32 @@ export default function RoomDetail() {
const [activeTab, setActiveTab] = useState('overview');
const [editRoom, setEditRoom] = useState(null);
const [saving, setSaving] = useState(false);
const [sharedUsers, setSharedUsers] = useState([]);
const [shareSearch, setShareSearch] = useState('');
const [shareResults, setShareResults] = useState([]);
const [shareSearching, setShareSearching] = useState(false);
// Federation invite state
const [showFedInvite, setShowFedInvite] = useState(false);
const [fedAddress, setFedAddress] = useState('');
const [fedMessage, setFedMessage] = useState('');
const [fedSending, setFedSending] = useState(false);
// Presentation state
const [uploadingPresentation, setUploadingPresentation] = useState(false);
const [removingPresentation, setRemovingPresentation] = useState(false);
const presentationInputRef = useRef(null);
const isOwner = room && user && room.user_id === user.id;
const isShared = room && !!room.shared;
const canManage = isOwner || isShared;
const fetchRoom = async () => {
try {
const res = await api.get(`/rooms/${uid}`);
setRoom(res.data.room);
setEditRoom(res.data.room);
if (res.data.sharedUsers) setSharedUsers(res.data.sharedUsers);
} catch {
toast.error(t('room.notFound'));
navigate('/dashboard');
@@ -144,6 +164,124 @@ export default function RoomDetail() {
toast.success(t('room.linkCopied'));
};
// Federation invite handler
const handlePresentationUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const allowedTypes = [
'application/pdf',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.presentation',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
if (!allowedTypes.includes(file.type)) {
toast.error('Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX');
return;
}
setUploadingPresentation(true);
try {
const arrayBuffer = await file.arrayBuffer();
const res = await api.post(`/rooms/${uid}/presentation`, arrayBuffer, {
headers: {
'Content-Type': file.type,
'X-Filename': encodeURIComponent(file.name),
},
});
setRoom(res.data.room);
setEditRoom(res.data.room);
toast.success(t('room.presentationUploaded'));
} catch (err) {
toast.error(err.response?.data?.error || t('room.presentationUploadFailed'));
} finally {
setUploadingPresentation(false);
if (presentationInputRef.current) presentationInputRef.current.value = '';
}
};
const handlePresentationRemove = async () => {
setRemovingPresentation(true);
try {
const res = await api.delete(`/rooms/${uid}/presentation`);
setRoom(res.data.room);
setEditRoom(res.data.room);
toast.success(t('room.presentationRemoved'));
} catch (err) {
toast.error(err.response?.data?.error || t('room.presentationRemoveFailed'));
} finally {
setRemovingPresentation(false);
}
};
const handleFedInvite = async (e) => {
e.preventDefault();
// Accept @user@domain or user@domain — must have a domain part
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
toast.error(t('federation.addressHint'));
return;
}
setFedSending(true);
try {
await api.post('/federation/invite', {
room_uid: uid,
to: fedAddress,
message: fedMessage || undefined,
});
toast.success(t('federation.sent'));
setShowFedInvite(false);
setFedAddress('');
setFedMessage('');
} catch (err) {
toast.error(err.response?.data?.error || t('federation.sendFailed'));
} finally {
setFedSending(false);
}
};
// Share functions
const searchUsers = async (query) => {
setShareSearch(query);
if (query.length < 2) {
setShareResults([]);
return;
}
setShareSearching(true);
try {
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
// Filter out already shared users
const sharedIds = new Set(sharedUsers.map(u => u.id));
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id)));
} catch {
setShareResults([]);
} finally {
setShareSearching(false);
}
};
const handleShare = async (userId) => {
try {
const res = await api.post(`/rooms/${uid}/shares`, { user_id: userId });
setSharedUsers(res.data.shares);
setShareSearch('');
setShareResults([]);
toast.success(t('room.shareAdded'));
} catch (err) {
toast.error(err.response?.data?.error || t('room.shareFailed'));
}
};
const handleUnshare = async (userId) => {
try {
const res = await api.delete(`/rooms/${uid}/shares/${userId}`);
setSharedUsers(res.data.shares);
toast.success(t('room.shareRemoved'));
} catch {
toast.error(t('room.shareFailed'));
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -203,7 +341,17 @@ export default function RoomDetail() {
</div>
<div className="flex items-center gap-2">
{isOwner && !status.running && (
{canManage && (
<button
onClick={() => setShowFedInvite(true)}
className="btn-ghost text-sm"
title={t('federation.inviteRemote')}
>
<Globe size={16} />
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
</button>
)}
{canManage && !status.running && (
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
{t('room.start')}
@@ -215,7 +363,7 @@ export default function RoomDetail() {
{t('room.join')}
</button>
)}
{isOwner && status.running && (
{canManage && status.running && (
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
{t('room.end')}
@@ -231,11 +379,10 @@ export default function RoomDetail() {
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-th-accent text-th-accent'
: 'border-transparent text-th-text-s hover:text-th-text hover:border-th-border'
}`}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === tab.id
? 'border-th-accent text-th-accent'
: 'border-transparent text-th-text-s hover:text-th-text hover:border-th-border'
}`}
>
<tab.icon size={16} />
{tab.label}
@@ -344,7 +491,7 @@ export default function RoomDetail() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
<input
type="number"
value={editRoom.max_participants}
@@ -354,7 +501,7 @@ export default function RoomDetail() {
/>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
<input
type="text"
value={editRoom.access_code || ''}
@@ -416,51 +563,173 @@ export default function RoomDetail() {
{/* Guest access section */}
<div className="pt-4 border-t border-th-border space-y-4">
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!editRoom.guest_access}
onChange={e => setEditRoom({ ...editRoom, guest_access: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
/>
<div>
<span className="text-sm text-th-text">{t('room.guestAccess')}</span>
<p className="text-xs text-th-text-s">{t('room.guestAccessHint')}</p>
</div>
</label>
{editRoom.guest_access && (
<>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
<input
type="text"
value={editRoom.moderator_code || ''}
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
className="input-field"
placeholder={t('room.moderatorCodeHint')}
/>
<p className="text-xs text-th-text-s mt-1">{t('room.moderatorCodeDesc')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestLink')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-th-bg-s px-3 py-2 rounded-lg text-xs text-th-text font-mono truncate border border-th-border">
{window.location.origin}/join/{room.uid}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/join/${room.uid}`);
toast.success(t('room.linkCopied'));
}}
className="btn-ghost text-xs py-2 px-3"
>
<Copy size={14} />
</button>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
<input
type="text"
value={editRoom.moderator_code || ''}
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
className="input-field"
placeholder={t('room.moderatorCodeHint')}
/>
<p className="text-xs text-th-text-s mt-1">{t('room.moderatorCodeDesc')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestLink')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-th-bg-s px-3 py-2 rounded-lg text-xs text-th-text font-mono truncate border border-th-border">
{window.location.origin}/join/{room.uid}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/join/${room.uid}`);
toast.success(t('room.linkCopied'));
}}
className="btn-ghost text-xs py-2 px-3"
>
<Copy size={14} />
</button>
</div>
</div>
</div>
{/* Presentation section */}
<div className="pt-4 border-t border-th-border space-y-4">
<div>
<h3 className="text-sm font-semibold text-th-text flex items-center gap-2 mb-1">
<FileText size={16} />
{t('room.presentationTitle')}
</h3>
<p className="text-xs text-th-text-s">{t('room.presentationDesc')}</p>
</div>
{room.presentation_file ? (
<div className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-2 min-w-0">
<FileText size={16} className="text-th-accent flex-shrink-0" />
<div className="min-w-0">
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
<p className="text-sm text-th-text font-medium truncate">
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`}
</p>
</div>
</div>
</>
<button
type="button"
onClick={handlePresentationRemove}
disabled={removingPresentation}
className="btn-ghost text-th-error hover:bg-th-error/10 flex-shrink-0 text-xs py-1.5 px-3"
>
{removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
{t('room.presentationRemove')}
</button>
</div>
) : (
<div className="text-xs text-th-text-s italic">{/* no presentation */}</div>
)}
<input
ref={presentationInputRef}
type="file"
accept=".pdf,.ppt,.pptx,.odp,.doc,.docx"
className="hidden"
onChange={handlePresentationUpload}
/>
<button
type="button"
onClick={() => presentationInputRef.current?.click()}
disabled={uploadingPresentation}
className="btn-secondary text-sm flex items-center gap-2"
>
{uploadingPresentation ? <Loader2 size={15} className="animate-spin" /> : <Upload size={15} />}
{t('room.presentationUpload')}
</button>
<p className="text-xs text-th-text-s">{t('room.presentationAllowedTypes')}</p>
</div>
{/* Share section */}
<div className="pt-4 border-t border-th-border space-y-4">
<h3 className="text-sm font-semibold text-th-text flex items-center gap-2">
<Share2 size={16} />
{t('room.shareTitle')}
</h3>
<p className="text-xs text-th-text-s">{t('room.shareDescription')}</p>
{/* User search */}
<div className="relative">
<div className="relative">
<UserPlus size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={shareSearch}
onChange={e => searchUsers(e.target.value)}
className="input-field pl-11"
placeholder={t('room.shareSearchPlaceholder')}
/>
</div>
{shareResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-th-card border border-th-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{shareResults.map(u => (
<button
key={u.id}
type="button"
onClick={() => handleShare(u.id)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{u.avatar_image ? (
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
) : (
(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Shared users list */}
{sharedUsers.length > 0 && (
<div className="space-y-2">
{sharedUsers.map(u => (
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{u.avatar_image ? (
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
) : (
(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</div>
<button
type="button"
onClick={() => handleUnshare(u.id)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors flex-shrink-0"
title={t('room.shareRemove')}
>
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
@@ -472,6 +741,46 @@ export default function RoomDetail() {
</div>
</form>
)}
{/* Federation Invite Modal */}
{showFedInvite && (
<Modal title={t('federation.inviteTitle')} onClose={() => setShowFedInvite(false)}>
<p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p>
<form onSubmit={handleFedInvite} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input
type="text"
value={fedAddress}
onChange={e => setFedAddress(e.target.value)}
className="input-field"
placeholder={t('federation.addressPlaceholder')}
required
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
<textarea
value={fedMessage}
onChange={e => setFedMessage(e.target.value)}
className="input-field resize-none"
rows={2}
placeholder={t('federation.messagePlaceholder')}
/>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-th-border">
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('federation.send')}
</button>
</div>
</form>
</Modal>
)}
</div>
);
}

View File

@@ -14,6 +14,7 @@ export default function Settings() {
const [profile, setProfile] = useState({
name: user?.name || '',
display_name: user?.display_name || '',
email: user?.email || '',
});
const [passwords, setPasswords] = useState({
@@ -52,6 +53,7 @@ export default function Settings() {
try {
const res = await api.put('/auth/profile', {
name: profile.name,
display_name: profile.display_name,
email: profile.email,
theme,
language,
@@ -190,7 +192,7 @@ export default function Settings() {
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-xl font-bold"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
>
{user?.name?.[0]?.toUpperCase() || '?'}
{(user?.display_name || user?.name)?.[0]?.toUpperCase() || '?'}
</div>
)}
<button
@@ -259,7 +261,7 @@ export default function Settings() {
<form onSubmit={handleProfileSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
@@ -270,6 +272,19 @@ export default function Settings() {
required
/>
</div>
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={profile.display_name}
onChange={e => setProfile({ ...profile, display_name: e.target.value })}
className="input-field pl-11"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>

87
src/pages/VerifyEmail.jsx Normal file
View File

@@ -0,0 +1,87 @@
import { useState, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { CheckCircle, XCircle, Loader2, Mail } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
export default function VerifyEmail() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const { t } = useLanguage();
const [status, setStatus] = useState('loading'); // loading | success | error
const [message, setMessage] = useState('');
useEffect(() => {
if (!token) {
setStatus('error');
setMessage(t('auth.verifyTokenMissing'));
return;
}
api.get(`/auth/verify-email?token=${token}`)
.then(() => {
setStatus('success');
setMessage(t('auth.verifySuccess'));
})
.catch(err => {
setStatus('error');
setMessage(err.response?.data?.error || t('auth.verifyFailed'));
});
}, [token]);
return (
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
{/* Animated background */}
<div className="absolute inset-0 bg-th-bg">
<div className="absolute inset-0 opacity-30">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
</div>
</div>
<div className="relative w-full max-w-md">
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
<div className="flex justify-center mb-8">
<BrandLogo size="lg" />
</div>
{status === 'loading' && (
<div className="space-y-4">
<Loader2 size={48} className="mx-auto animate-spin text-th-accent" />
<p className="text-th-text">{t('auth.verifying')}</p>
</div>
)}
{status === 'success' && (
<div className="space-y-4">
<CheckCircle size={48} className="mx-auto text-green-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.verifySuccessTitle')}</h2>
<p className="text-th-text-s">{message}</p>
<Link to="/login" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.login')}
</Link>
</div>
)}
{status === 'error' && (
<div className="space-y-4">
<XCircle size={48} className="mx-auto text-red-400" />
<h2 className="text-2xl font-bold text-th-text">{t('auth.verifyFailedTitle')}</h2>
<p className="text-th-text-s">{message}</p>
<Link to="/register" className="btn-primary inline-flex items-center gap-2 mt-4">
{t('auth.register')}
</Link>
</div>
)}
<Link to="/" className="block mt-6 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
{t('auth.backToHome')}
</Link>
</div>
</div>
</div>
);
}

View File

@@ -104,6 +104,13 @@ export const themes = [
group: 'Community',
colors: { bg: '#0d1117', accent: '#58a6ff', text: '#c9d1d9' },
},
{
id: 'scrunkly-cat',
name: 'scrunkly.cat-dark',
type: 'dark',
group: 'Community',
colors: { bg: '#161924', accent: '#b30051', text: '#dadada' },
},
];
export function getThemeById(id) {