Compare commits

...

17 commits
0.0.3 ... main

Author SHA1 Message Date
rnsrk
e193f58008 update readme 2026-06-26 09:34:11 +02:00
rnsrk
6743e5e36d add forgejo, delete gitlab (and orphan onlyoffice) 2026-06-26 09:31:32 +02:00
rnsrk
4f62c7ba5b add nextcloud 2026-03-30 12:22:16 +02:00
rnsrk
36a84a9da4 add nextcloud 2026-03-30 12:21:33 +02:00
rnsrk
3ff0d9e1b9 delete nextcloud not necessary files 2026-03-30 11:34:33 +02:00
rnsrk
c726ff18f1 Add WireGuard VPN and restrict SSH to VPN clients only 2026-03-30 11:33:59 +02:00
rnsrk
f8b8f53d54 Add Drupal headless stack with Next.js frontend
- Add Next.js frontend service (nextjs) with Dockerfile and source
- Update docker-compose.yml: image names, Drupal 11.3.3, nextjs service
- Add docker-compose.override.yml.disabled for dev hot-reload
- Add install-headless-modules.sh for OAuth/JSON:API module setup
- Add README.md with full setup and configuration guide
- Update nginx/Dockerfile and nginx.conf.template for cms. subdomain
- Update drupal/Dockerfile PHP-FPM build args
- Gitignore **/.vscode/ to prevent IDE workspace files from being tracked
2026-03-30 11:14:17 +02:00
rnsrk
71a8dac389 better postinstall apps 2026-03-30 11:10:11 +02:00
rnsrk
fb22e9cab4 now with dnsec 2026-01-19 13:10:13 +01:00
rnsrk
b006c8f809 small add for DNSSEC and MTA-STS 2026-01-19 09:10:13 +01:00
rnsrk
f670bec1b1 add better mail security 2026-01-19 08:57:23 +01:00
rnsrk
a7585e1bc9 flaged scrips as executable 2025-05-12 00:39:00 +02:00
rnsrk
929bb12447 now with working proxy 2025-05-12 00:35:58 +02:00
rnsrk
29db83e9f8 no ci/cd 2025-05-11 22:33:24 +02:00
rnsrk
c2b8dc716b Update GitLab CI to save changelog to CHANGELOG file 2025-05-11 22:30:24 +02:00
rnsrk
cec51554d0 Add automatic release pipeline 2025-05-11 22:21:54 +02:00
rnsrk
3e1d5cf1b2 add CHANGELOG 2025-05-11 22:13:17 +02:00
291 changed files with 19433 additions and 9231 deletions

7
.gitignore vendored
View file

@ -1,8 +1,14 @@
# General
bkp/*
.cursor
**/.vscode/
.env
**/.env
*.log
*.out
*.tmp
HEALTH_CHECK_REPORT.md
/core/mta-sts/.well-known/mta-sts.txt
secrets/*
!secrets/.gitkeep
**/.git
@ -97,3 +103,4 @@ nextcloud/hooks/post-installation/set-configs.sh
**/volumes/
mailcow/data/conf/dovecot/auth/passwd-verify.lua
mailcow/data/conf/postfix/main.cf
passwd-verify.lua

12
CHANGELOG Normal file
View file

@ -0,0 +1,12 @@
0.0.3
- Fix the postgres-loose-it-all-volume mistake...
+ Volume have to be `/var/lib/postgresql/data` not `/var/lib/postgresql/` or everthing will be deleted, if the container is rebuild.
- More robust Nextcloud config.
- Better reuse of gits for updating and maintainment.
0.0.2
- Make things more agnostic by
+ use original gits
+ have envs and overrides in parent dirs
- better automatisation with copy_overrides-bash and create_infra.bash on root level.
0.0.1
- First release.

204
README.md
View file

@ -3,22 +3,29 @@
This repository contains a productive stack of open-source applications for team collaboration and communication. The stack includes:
### Core Infrastructure
- **Traefik**: Edge router that handles routing and load balancing for all services
- **Traefik**: Edge router that handles routing, TLS (Let's Encrypt) and load balancing for all services
- **PostgreSQL**: Relational database for applications requiring PostgreSQL
- **MariaDB**: MySQL-compatible database for applications requiring MySQL
- **Adminer**: Database management tool for easy database administration
- **WireGuard**: VPN gateway; SSH is reachable only from within the VPN subnet
- **MTA-STS**: Static policy host for mail transport security
### Code Hosting & CI
- **Forgejo**: Self-hosted Git forge (replaces GitLab) at `git.${DOMAIN}`, SSH on port 2424
- **Forgejo Actions Runner**: CI runner with an isolated Docker-in-Docker engine for `.forgejo/workflows`
### Mail
- **Mailcow**: Complete mail server solution with SMTP, IMAP, antivirus, and webmail
### Collaboration Tools
- **Nextcloud**: Self-hosted file sync and share platform with collaboration features
- **Nextcloud**: Self-hosted file sync and share platform (with Collabora/Talk)
- **OnlyOffice**: Online office suite for document editing and collaboration
- **OpenProject**: Project management and team collaboration software
- **HedgeDoc**: Collaborative markdown notes editor for team documentation
### Web Publishing
- **Drupal**: Flexible content management system (CMS) for building websites
- **Drupal**: Headless content management system (CMS), PHP-FPM + NGINX, Redis cache
- **Next.js**: Decoupled frontend (`${DOMAIN}`) consuming the Drupal backend
All components are containerized using Docker for easy deployment, scaling, and management, creating a complete productivity environment for teams.
@ -27,6 +34,28 @@ You need [docker](https://docs.docker.com/get-started/get-docker/) and with [doc
At least 6 cores with 16GB RAM 100GB SSD would be sufficent.
## Diagnostics
Run the unified diagnostics script from the repository root:
```bash
./diagnostic.sh
```
This combines the previous `diagnostic.sh` and `health_check.sh` checks.
## Mail Security
### Current Status
- ✅ SPF, DKIM, DMARC configured
- ✅ MTA-STS policy enforced (`https://mta-sts.nasarek.dev/.well-known/mta-sts.txt`)
- ✅ TLS-RPT configured
- ✅ TLS certificates valid on all mail ports
- ⚠️ DNSSEC: Enable at DNS provider and ensure DS/DNSKEY are published
- ⚠️ TLSA (DANE): Add records after DNSSEC is active (see `/var/deploy/scripts/README-TLSA.md`)
### TLSA Record Automation
Automated TLSA record updates are available. See `/var/deploy/scripts/README-TLSA.md` for setup instructions.
The automation monitors certificate changes and updates TLSA records automatically when certificates are renewed.
## Install
### Prerequisites
1) Copy the env and docker-compose.override.yml to the service directories via the script.
@ -35,7 +64,7 @@ Add your specific settings in `set-config.sh`, then:
```
./copy_overrides.bash
```
2) Set your env variables in `./core/.env`, `./drupal/.env`, `./gitlab/.env`, `./hedgedoc/.env`, `./onlyoffice/.env`, `./nextcloud/.env`, `./openproject/.env`.
2) Set your env variables in `./core/.env`, `./drupal/.env`, `./forgejo/.env`, `./hedgedoc/.env`, `./onlyoffice/.env`, `./nextcloud/.env`, `./openproject/.env`.
3) Generate mailcow config
```bash
@ -57,9 +86,9 @@ cp mailcow.conf .env
docker compose -f core/docker-compose.yml up -d
```
### Drupal
1) Set your env variables in
2) Start Drupal containers.
### Drupal + Next.js
1) Set your env variables in `drupal/.env` and `nextjs/.env.local`.
2) Start the stack (NGINX, Drupal PHP-FPM, Redis and the Next.js frontend).
```bash
docker compose -f drupal/docker-compose.yml up -d
```
@ -80,18 +109,38 @@ $settings['cache']['bins']['config'] = 'cache.backend.chainedfast';
4) Visit your Domain and install Drupal site.
### Gitlab
1) Start gitlab.
```bash
docker compose -f gitlab/docker-compose.yml up -d
```
2) Get your root password.
```bash
sudo docker exec -it gitlab grep 'Password:'
/etc/gitlab/initial_root_password
```
### Forgejo
Git forge (replaces GitLab). Uses the shared Postgres DB and Traefik. SSH is
served over the Traefik `forgejo-ssh` TCP entrypoint on port 2424.
4) Visit you domain and log in.
1) Start Forgejo.
```bash
docker compose -f forgejo/docker-compose.yml up -d
```
2) Create the first admin user (headless install is enabled, so skip the web installer).
```bash
docker exec -u 1000 forgejo forgejo admin user create \
--admin --username <you> --email <you@domain> --random-password
```
3) Visit `https://git.${DOMAIN}` and log in. Add your SSH key under
*Settings → SSH/GPG Keys*. Clone URLs use `git@git.${DOMAIN}:<owner>/<repo>.git`
on SSH port 2424.
#### CI runner (Forgejo Actions)
1) Generate a registration token and put it in `forgejo-runner/.env`:
```bash
docker exec -u 1000 forgejo forgejo actions generate-runner-token
```
2) Start the runner (registers automatically on first boot; CI jobs run in
an isolated Docker-in-Docker engine).
```bash
docker compose -f forgejo-runner/docker-compose.yml up -d
```
Workflows live in each repo under `.forgejo/workflows/*.yml` (GitHub-Actions syntax).
#### Migrating from GitLab
A helper script migrates all GitLab projects (code, issues, MRs, labels,
milestones, releases, wiki). See `forgejo/migrate-from-gitlab.sh`.
### Hedgedoc
1) Start containers.
@ -108,7 +157,7 @@ docker exec hedgedoc bin/manage_users --pass ${HEDGEDOC_USER_PASSWORD} --add ${H
### Mailcow
1) Start containers.
```bash
docker compose docker-compose.yml up -d
cd mailcow && docker compose up -d
```
4) Visit DOMAIN/admin and log in with admin:admin.
@ -133,30 +182,115 @@ docker compose -f nextcloud/docker-compose.yml up -d
```
3) Visit nextcloud domain and login with your .env credentials.
#### Nextcloud Office with Collabora
Collabora container is included for document editing. Configure via Nextcloud admin panel:
1) Install **Nextcloud Office** app from Apps menu.
2) Go to **Settings****Administration****Office**.
3) Select **"Use your own server"** and enter: `https://office.yourdomain.com`.
4) Configure WOPI allowlist with Collabora's IP:
```bash
# Get Collabora IP.
docker inspect nextcloud-collabora --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
# Set allowlist.
docker exec -u www-data nextcloud php occ config:app:set richdocuments wopi_allowlist --value="COLLABORA_IP"
# Enable local servers.
docker exec -u www-data nextcloud php occ config:system:set allow_local_remote_servers --value=true --type=boolean
```
See `/var/deploy/nextcloud/COLLABORA-QUICK-SETUP.md` for details.
### Openproject
1) Start containers.
```bash
docker compose -f hedgedoc/docker-compose.yml up -d
docker compose -f openproject/docker-compose.yml up -d
```
2) Visit openproject domain and login with admin:admin and set new password.
## SSH over VPN (WireGuard)
SSH access is secured behind a WireGuard VPN. Port 22 is only reachable from within the VPN subnet (`10.13.13.0/24`).
### Server-side setup
**1. Configure `core/.env`**
```env
WG_SERVERURL=your.server.hostname.or.ip
WG_PEERS=laptop,phone # or a number, e.g. "3"
TZ=Europe/Berlin
```
**2. Start the WireGuard container**
```bash
docker compose -f core/docker-compose.yml up -d wireguard
```
Peer configs and QR codes are generated automatically in:
```
core/volumes/wireguard/config/peer_<name>/
peer_<name>.conf ← import this on the client
peer_<name>.png ← scan this QR code on mobile
```
**3. Apply firewall rules**
Run *after* confirming the VPN works (see client setup below):
```bash
sudo bash scripts/secure-ssh-vpn.sh [--dry-run]
```
This opens ports 80, 443, 25, 465, 587, 143, 993, 4190, 2424, 51820 and restricts SSH to VPN clients only. To also restrict mail client ports (IMAP, submission) to VPN:
```bash
sudo bash scripts/secure-ssh-vpn.sh --mail-vpn-only
```
### Local client setup
#### Linux
```bash
sudo apt install wireguard
# Copy peer config from the server
scp user@your-server:/var/deploy/core/volumes/wireguard/config/peer_laptop/peer_laptop.conf \
~/.config/wireguard/wg0.conf
sudo wg-quick up wg0
# Connect on boot:
sudo systemctl enable wg-quick@wg0
```
#### macOS
```bash
brew install wireguard-tools
# Or use the App Store app: "WireGuard"
# Import peer_laptop.conf via File → Import Tunnel(s) from File
```
#### Windows
Download [WireGuard for Windows](https://www.wireguard.com/install/), then:
*Add Tunnel → Import tunnel(s) from file* → select `peer_laptop.conf`.
#### Android / iOS
Scan the QR code at `core/volumes/wireguard/config/peer_<name>/peer_<name>.png` with the WireGuard app.
### Verify the tunnel
```bash
# On the server — check connected peers
docker exec wireguard wg show
# From the client — SSH should work only after connecting to VPN
ssh user@10.13.13.1
```
## Roadmap
- Tweak the core components and subservices for petter performance.
- More automatisation when installing the environment.
- Add more services
- [ ] Matrix/Synapse + Element
- Better reuse of gits for updating and maintainment.
## Changelog
0.0.3
- Fix the postgres-loose-it-all-volume mistake...
+ Volume have to be `/var/lib/postgresql/data` not `/var/lib/postgresql/` or everthing will be deleted, if the container is rebuild.
- More robust Nextcloud config.
0.0.2
- Make things more agnostic by
+ use original gits
+ have envs and overrides in parent dirs
- better automatisation with copy_overrides-bash and create_infra.bash on root level.
0.0.1
- First release.
- Fix all the Nextcloud errors
+ Your web server is not properly set up to resolve `.well-known` URLs, failed on: `/.well-known/webfinger` For more details see the documentation ↗.

View file

@ -3,7 +3,7 @@
echo "Copying environment variables..."
cp override/core.example.env core/.env
cp override/drupal.example.env drupal/.env
cp override/gitlab.example.env gitlab/.env
cp override/forgejo.example.env forgejo/.env
cp override/nextcloud.example.env nextcloud/.env
cp override/onlyoffice.example.env onlyoffice/.env
cp override/openproject.example.env openproject/.env

View file

@ -1,7 +1,33 @@
services:
# VPN — WireGuard server. Clients must connect before SSH is reachable.
# network_mode: host is required so wg0 is created on the host network stack,
# making 10.13.13.1 reachable by sshd and other host services.
wireguard:
image: linuxserver/wireguard:latest
container_name: wireguard
network_mode: host
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
- PUID=1000
- PGID=1000
- TZ=${TZ:-Europe/Berlin}
- SERVERURL=${WG_SERVERURL}
- SERVERPORT=51820
- PEERS=${WG_PEERS}
- PEERDNS=auto
- INTERNAL_SUBNET=10.13.13.0
- ALLOWEDIPS=10.13.13.0/24
- LOG_CONFS=false
volumes:
- ./volumes/wireguard/config:/config
- /lib/modules:/lib/modules:ro
restart: no
# Database-Stack
adminer:
image: adminer
image: adminer:5
container_name: adminer
depends_on:
- mariadb
@ -21,7 +47,7 @@ services:
restart: unless-stopped
mariadb:
image: mariadb:11.5.2
image: mariadb:12
container_name: mariadb
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
@ -49,7 +75,7 @@ services:
# Traefik
traefik:
image: traefik:3.3
image: traefik:3
container_name: traefik
labels:
- "traefik.enable=true"
@ -64,6 +90,10 @@ services:
- "traefik.http.middlewares.nextcloud-headers.headers.stsPreload=true"
- "traefik.http.middlewares.nextcloud-headers.headers.forceSTSHeader=true"
# Timeout middlewares
- "traefik.http.middlewares.timeout.headers.customrequestheaders.X-Forwarded-Timeout=120"
- "traefik.http.middlewares.timeout.headers.customresponseheaders.X-Response-Timeout=120"
# routers
- "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)"
- "traefik.http.routers.traefik.entrypoints=web,websecure"
@ -80,8 +110,8 @@ services:
- --providers.docker
# Disable exposing services without Traefik labels
- --providers.docker.exposedbydefault=false
# Listen on port 2424 for SSH requests
- --entrypoints.gitlab-ssh.address=:2424
# Listen on port 2424 for Forgejo SSH requests
- --entrypoints.forgejo-ssh.address=:2424
# Listen on port 80 for HTTP requests
- --entrypoints.web.address=:80
# Listen on port 443 for HTTPS requests
@ -89,7 +119,7 @@ services:
# Redirect HTTP requests to HTTPS
- --entrypoints.web.http.redirections.entryPoint.to=websecure
- --entrypoints.web.http.redirections.entryPoint.scheme=https
- --entrypoints.web.http.redirections.entrypoint.permanent=true
- --entrypoints.web.http.redirections.entryPoint.permanent=true
# Use the specified email address for Let's Encrypt certificate requests
- --certificatesresolvers.le.acme.email=${TRAEFIK_EMAIL}
# Use the HTTP challenge for Let's Encrypt certificate requests
@ -104,6 +134,10 @@ services:
- --log.level=INFO
# Enable the Traefik API
- --api
# Set global timeouts
- --serverstransport.forwardingtimeouts.dialtimeout=120s
- --serverstransport.forwardingtimeouts.responseheadertimeout=120s
- --serverstransport.forwardingtimeouts.idleconntimeout=120s
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- server-certificates:/certificates
@ -115,6 +149,23 @@ services:
- traefik
restart: unless-stopped
mta-sts:
image: nginx:1.27-alpine
container_name: mta-sts
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.mta-sts.rule=Host(`mta-sts.${DOMAIN}`)"
- "traefik.http.routers.mta-sts.entrypoints=websecure"
- "traefik.http.routers.mta-sts.tls=true"
- "traefik.http.routers.mta-sts.tls.certresolver=le"
- "traefik.http.services.mta-sts.loadbalancer.server.port=80"
volumes:
- ./mta-sts:/usr/share/nginx/html:ro
networks:
- traefik
restart: unless-stopped
volumes:
server-certificates:
name: server-certificates

View file

@ -0,0 +1,4 @@
version: STSv1
mode: enforce
mx: {{MAILCOW_HOSTNAME}}
max_age: 604800

View file

@ -1,9 +1,39 @@
#!/bin/bash
source ./core/.env
set -e
source ./core/.env
source ./drupal/.env
create_mta_sts_policy() {
local template_path="./core/mta-sts/.well-known/mta-sts.txt.template"
local output_path="./core/mta-sts/.well-known/mta-sts.txt"
local mailcow_conf="./mailcow/mailcow.conf"
local mailcow_hostname=""
if [ ! -f "$template_path" ]; then
return 0
fi
if [ -f "$mailcow_conf" ]; then
mailcow_hostname=$(grep '^MAILCOW_HOSTNAME=' "$mailcow_conf" | cut -d= -f2)
fi
if [ -z "$mailcow_hostname" ] && [ -n "$DOMAIN" ]; then
mailcow_hostname="mail.${DOMAIN}"
fi
if [ ! -f "$output_path" ]; then
mkdir -p "$(dirname "$output_path")"
sed "s/{{MAILCOW_HOSTNAME}}/${mailcow_hostname}/g" "$template_path" > "$output_path"
echo "Created MTA-STS policy at ${output_path}"
else
echo "MTA-STS policy already exists at ${output_path}"
fi
}
create_mta_sts_policy
if [ ! -d "./drupal/drupal/root" ]; then
echo "Creating Drupal infrastructure..."
mkdir -p ./drupal/drupal/root
@ -44,3 +74,10 @@ docker exec postgres psql -U $POSTGRES_USER -d postgres -c "CREATE DATABASE $OPE
docker exec postgres psql -U $POSTGRES_USER -d $OPENPROJECT_DB_NAME -c "GRANT ALL PRIVILEGES ON DATABASE $OPENPROJECT_DB_NAME TO $OPENPROJECT_DB_USER;"
echo "OpenProject infrastructure created"
echo "Creating Forgejo infrastructure..."
source ./forgejo/.env
docker exec postgres psql -U $POSTGRES_USER -d postgres -c "CREATE USER $FORGEJO_DB_USER WITH PASSWORD '$FORGEJO_DB_PASSWORD';"
docker exec postgres psql -U $POSTGRES_USER -d postgres -c "CREATE DATABASE $FORGEJO_DB_NAME OWNER $FORGEJO_DB_USER;"
docker exec postgres psql -U $POSTGRES_USER -d $FORGEJO_DB_NAME -c "GRANT ALL PRIVILEGES ON DATABASE $FORGEJO_DB_NAME TO $FORGEJO_DB_USER;"
echo "Forgejo infrastructure created"

View file

@ -1,164 +1,515 @@
#!/bin/bash
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Colors for output.
green='\033[0;32m'
yellow='\033[1;33m'
red='\033[0;31m'
blue='\033[0;34m'
noColor='\033[0m'
echo -e "${YELLOW}Running diagnostic checks for Open Productive Stack...${NC}"
printSection() {
echo -e "\n${blue}--- $1 ---${noColor}"
}
# Function to check if a service is running
check_service() {
local service=$1
checkStatus() {
local status="$1"
local message="$2"
echo -e "${YELLOW}Checking if $service is running...${NC}"
if [ "$status" = "0" ] || [ "$status" = "true" ] || [ "$status" = "OK" ] || [ "$status" = "healthy" ]; then
echo -e "${green}${noColor} $message"
return 0
fi
echo -e "${red}${noColor} $message"
return 1
}
warnStatus() {
local message="$1"
echo -e "${yellow}${noColor} $message"
}
checkService() {
local service="$1"
echo -e "${yellow}Checking if ${service} is running...${noColor}"
if docker ps | grep -q "$service"; then
echo -e "${GREEN}$service is running.${NC}"
echo -e "${green}${service} is running.${noColor}"
return 0
else
echo -e "${RED}$service is not running.${NC}"
return 1
fi
echo -e "${red}${service} is not running.${noColor}"
return 1
}
# Function to check network connectivity
check_connectivity() {
local service=$1
local port=$2
local host=${3:-localhost}
checkConnectivity() {
local service="$1"
local port="$2"
local host="${3:-localhost}"
echo -e "${YELLOW}Checking connectivity to $service on $host:$port...${NC}"
echo -e "${yellow}Checking connectivity to ${service} on ${host}:${port}...${noColor}"
if nc -z -v -w5 "$host" "$port" 2>/dev/null; then
echo -e "${GREEN}Connection to $service on $host:$port successful.${NC}"
echo -e "${green}Connection to ${service} on ${host}:${port} successful.${noColor}"
return 0
fi
echo -e "${red}Cannot connect to ${service} on ${host}:${port}.${noColor}"
return 1
}
loadCoreDomain() {
local coreEnvFile="/var/deploy/core/.env"
if [ -f "$coreEnvFile" ]; then
# shellcheck disable=SC1090
source "$coreEnvFile"
echo -e "${yellow}Domain configuration:${noColor} ${DOMAIN}"
return 0
fi
echo -e "${red}Core .env file not found.${noColor}"
DOMAIN="example.com"
return 1
}
loadMailcowHostname() {
local mailcowConfFile="/var/deploy/mailcow/mailcow.conf"
if [ -f "$mailcowConfFile" ]; then
mailcowHostname=$(grep '^MAILCOW_HOSTNAME=' "$mailcowConfFile" | cut -d= -f2)
return 0
fi
mailcowHostname=""
return 1
}
checkTraefik() {
echo -e "${yellow}Checking Traefik configuration...${noColor}"
checkService "traefik" || return 1
checkConnectivity "Traefik HTTP" 80 || echo -e "${red}Traefik HTTP port not accessible.${noColor}"
checkConnectivity "Traefik HTTPS" 443 || echo -e "${red}Traefik HTTPS port not accessible.${noColor}"
checkConnectivity "Traefik SSH" 2424 || echo -e "${red}Traefik SSH port not accessible.${noColor}"
echo -e "${yellow}Checking Traefik certificates...${noColor}"
if docker exec traefik ls -la /certificates/acme.json >/dev/null 2>&1; then
echo -e "${green}Traefik certificates found.${noColor}"
else
echo -e "${RED}Cannot connect to $service on $host:$port.${NC}"
echo -e "${red}Traefik certificates not found.${noColor}"
fi
return 0
}
checkForgejo() {
echo -e "${yellow}Checking Forgejo configuration...${noColor}"
checkService "forgejo" || return 1
docker exec forgejo grep -q "ROOT_URL" /data/gitea/conf/app.ini && \
echo -e "${green}Forgejo root URL is configured.${noColor}" || \
echo -e "${red}Forgejo root URL is not configured.${noColor}"
docker exec forgejo grep -q "SSH_PORT" /data/gitea/conf/app.ini && \
echo -e "${green}Forgejo SSH port is configured.${noColor}" || \
echo -e "${red}Forgejo SSH port is not configured.${noColor}"
echo -e "${yellow}Checking Forgejo SSH connection...${noColor}"
if ssh -T git@git.${DOMAIN} -p 2424 -o StrictHostKeyChecking=no -o BatchMode=yes &>/dev/null; then
echo -e "${green}Forgejo SSH connection successful.${noColor}"
else
echo -e "${red}Forgejo SSH connection failed. This is expected if you haven't set up SSH keys yet.${noColor}"
echo -e "${yellow}Try: ssh -vT git@git.${DOMAIN} -p 2424${noColor}"
fi
return 0
}
checkDatabases() {
echo -e "${yellow}Checking database services...${noColor}"
checkService "mariadb" && \
echo -e "${green}MariaDB is running.${noColor}" || \
echo -e "${red}MariaDB is not running.${noColor}"
checkService "postgres" && \
echo -e "${green}PostgreSQL is running.${noColor}" || \
echo -e "${red}PostgreSQL is not running.${noColor}"
return 0
}
checkNextcloud() {
echo -e "${yellow}Checking Nextcloud configuration...${noColor}"
checkService "nextcloud" || return 1
checkService "nextcloud-redis" || return 1
checkService "nextcloud-reverse-proxy" || return 1
echo -e "${yellow}Checking Nextcloud status...${noColor}"
if docker exec nextcloud php /var/www/html/occ status 2>&1 | grep -q "installed: true"; then
echo -e "${green}Nextcloud is installed and operational.${noColor}"
docker exec nextcloud php /var/www/html/occ status 2>&1 | grep -E "version|maintenance" | sed 's/^/ /'
else
echo -e "${red}Nextcloud is not properly installed.${noColor}"
return 1
fi
}
# Function to check Traefik configuration
check_traefik() {
echo -e "${YELLOW}Checking Traefik configuration...${NC}"
# Check if Traefik is running
check_service "traefik" || return 1
# Check Traefik ports
check_connectivity "Traefik HTTP" 80 || echo -e "${RED}Traefik HTTP port not accessible.${NC}"
check_connectivity "Traefik HTTPS" 443 || echo -e "${RED}Traefik HTTPS port not accessible.${NC}"
check_connectivity "Traefik SSH" 2424 || echo -e "${RED}Traefik SSH port not accessible.${NC}"
# Check Traefik certificates
echo -e "${YELLOW}Checking Traefik certificates...${NC}"
if docker exec traefik ls -la /certificates/acme.json >/dev/null 2>&1; then
echo -e "${GREEN}Traefik certificates found.${NC}"
echo -e "${yellow}Checking Redis connectivity...${noColor}"
if docker exec nextcloud-redis redis-cli ping 2>&1 | grep -q "PONG"; then
echo -e "${green}Redis is responding.${noColor}"
else
echo -e "${RED}Traefik certificates not found.${NC}"
echo -e "${red}Redis is not responding.${noColor}"
return 1
fi
echo -e "${yellow}Checking database collation...${noColor}"
local collationCheck
collationCheck=$(docker exec nextcloud php /var/www/html/occ status 2>&1 | grep -i "collation")
if [ -n "$collationCheck" ]; then
echo -e "${yellow}Database collation version mismatch detected.${noColor}"
echo -e "${yellow}Run: ./nextcloud-maintenance.sh collation${noColor}"
else
echo -e "${green}Database collation is up to date.${noColor}"
fi
return 0
}
# Function to check GitLab configuration
check_gitlab() {
echo -e "${YELLOW}Checking GitLab configuration...${NC}"
checkAllServices() {
echo -e "${yellow}Checking all services...${noColor}"
# Check if GitLab is running
check_service "gitlab" || return 1
# Check GitLab HTTP port
docker exec gitlab grep -q "external_url" /etc/gitlab/gitlab.rb && \
echo -e "${GREEN}GitLab external URL is configured.${NC}" || \
echo -e "${RED}GitLab external URL is not configured.${NC}"
# Check GitLab SSH port
docker exec gitlab grep -q "gitlab_shell_ssh_port" /etc/gitlab/gitlab.rb && \
echo -e "${GREEN}GitLab SSH port is configured.${NC}" || \
echo -e "${RED}GitLab SSH port is not configured.${NC}"
# Check GitLab SSH connection
echo -e "${YELLOW}Checking GitLab SSH connection...${NC}"
if ssh -T git@gitlab.${DOMAIN} -p 2424 -o StrictHostKeyChecking=no -o BatchMode=yes &>/dev/null; then
echo -e "${GREEN}GitLab SSH connection successful.${NC}"
else
echo -e "${RED}GitLab SSH connection failed. This is expected if you haven't set up SSH keys yet.${NC}"
echo -e "${YELLOW}Try: ssh -vT git@gitlab.${DOMAIN} -p 2424${NC}"
fi
return 0
}
# Function to check database services
check_databases() {
echo -e "${YELLOW}Checking database services...${NC}"
# Check MariaDB
check_service "mariadb" && \
echo -e "${GREEN}MariaDB is running.${NC}" || \
echo -e "${RED}MariaDB is not running.${NC}"
# Check PostgreSQL
check_service "postgres" && \
echo -e "${GREEN}PostgreSQL is running.${NC}" || \
echo -e "${RED}PostgreSQL is not running.${NC}"
return 0
}
# Function to check all other services
check_all_services() {
echo -e "${YELLOW}Checking all services...${NC}"
local services=("traefik" "gitlab" "mariadb" "postgres" "adminer" "nextcloud" "onlyoffice" "openproject" "hedgedoc" "drupal")
local services=("traefik" "forgejo" "mariadb" "postgres" "adminer" "nextcloud" "onlyoffice" "openproject" "hedgedoc" "drupal")
for service in "${services[@]}"; do
check_service "$service"
checkService "$service"
done
return 0
}
# Check Docker and Docker Compose
echo -e "${YELLOW}Checking Docker and Docker Compose installation...${NC}"
checkMailcowServices() {
printSection "1. Service Health Status"
echo -e "${yellow}Checking Docker services...${noColor}"
local traefikStatus
traefikStatus=$(docker ps --filter "name=traefik" --format "{{.Status}}" | grep -q "Up" && echo "OK" || echo "FAIL")
checkStatus "$traefikStatus" "Traefik is running"
local mailcowServices
local totalMailcow
mailcowServices=$(cd /var/deploy/mailcow && docker compose ps --format json 2>/dev/null | jq -r '.State' | grep -c "running" 2>/dev/null || echo "0")
totalMailcow=$(cd /var/deploy/mailcow && docker compose ps --format json 2>/dev/null | jq -r '.State' | wc -l 2>/dev/null || echo "0")
if [ "$mailcowServices" = "$totalMailcow" ] && [ "$totalMailcow" -gt 0 ]; then
checkStatus "OK" "All Mailcow services running (${mailcowServices}/${totalMailcow})"
else
checkStatus "FAIL" "Some Mailcow services not running (${mailcowServices}/${totalMailcow})"
fi
local criticalServices=("nginx-mailcow" "postfix-mailcow" "dovecot-mailcow" "mysql-mailcow" "acme-mailcow" "watchdog-mailcow")
for service in "${criticalServices[@]}"; do
local serviceStatus
serviceStatus=$(cd /var/deploy/mailcow && docker compose ps --format json 2>/dev/null | jq -r "select(.Service==\"$service\") | .State" | head -1)
if [ "$serviceStatus" = "running" ]; then
checkStatus "OK" "${service} is running"
else
checkStatus "FAIL" "${service} is not running"
fi
done
}
checkSslAndCerts() {
printSection "2. SSL/TLS Configuration"
echo -e "${yellow}Traefik SSL Configuration:${noColor}"
local traefikHttpChallenge
local traefikTlsChallenge
traefikHttpChallenge=$(docker exec traefik cat /proc/1/cmdline 2>/dev/null | tr '\0' '\n' | grep -q "httpchallenge" && echo "OK" || echo "FAIL")
traefikTlsChallenge=$(docker exec traefik cat /proc/1/cmdline 2>/dev/null | tr '\0' '\n' | grep -q "tlschallenge" && echo "OK" || echo "FAIL")
checkStatus "$traefikHttpChallenge" "Traefik HTTP challenge configured"
checkStatus "$traefikTlsChallenge" "Traefik TLS-ALPN-01 challenge configured"
local acmeFile
acmeFile=$(docker exec traefik test -f /certificates/acme.json && echo "OK" || echo "FAIL")
checkStatus "$acmeFile" "Traefik acme.json exists"
local certCount
certCount=$(docker exec traefik cat /certificates/acme.json 2>/dev/null | jq -r '.le.Certificates | length' 2>/dev/null || echo "0")
if [ "$certCount" -gt 0 ]; then
checkStatus "OK" "Traefik has ${certCount} certificate(s) stored"
else
checkStatus "FAIL" "Traefik has no certificates"
fi
local mailcowCert
local mailcowKey
mailcowCert=$(cd /var/deploy/mailcow && test -f data/assets/ssl/cert.pem && echo "OK" || echo "FAIL")
mailcowKey=$(cd /var/deploy/mailcow && test -f data/assets/ssl/key.pem && echo "OK" || echo "FAIL")
checkStatus "$mailcowCert" "Mailcow certificate file exists"
checkStatus "$mailcowKey" "Mailcow private key exists"
if [ -f /var/deploy/mailcow/data/assets/ssl/cert.pem ]; then
local certExpiry
local certExpiryEpoch
local currentEpoch
local daysUntilExpiry
certExpiry=$(openssl x509 -in /var/deploy/mailcow/data/assets/ssl/cert.pem -noout -enddate 2>/dev/null | cut -d= -f2)
certExpiryEpoch=$(date -d "$certExpiry" +%s 2>/dev/null || echo "0")
currentEpoch=$(date +%s)
daysUntilExpiry=$(( (certExpiryEpoch - currentEpoch) / 86400 ))
if [ "$daysUntilExpiry" -gt 30 ]; then
checkStatus "OK" "Certificate valid for ${daysUntilExpiry} more days (expires: ${certExpiry})"
elif [ "$daysUntilExpiry" -gt 0 ]; then
warnStatus "Certificate expires in ${daysUntilExpiry} days (expires: ${certExpiry})"
else
checkStatus "FAIL" "Certificate expired on ${certExpiry}"
fi
fi
if [ -n "$mailcowHostname" ]; then
local httpsResponse
local certChain
httpsResponse=$(curl -sI "https://${mailcowHostname}" 2>&1 | head -1 | grep -q "HTTP" && echo "OK" || echo "FAIL")
certChain=$(echo | openssl s_client -connect "${mailcowHostname}:443" -servername "${mailcowHostname}" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null | grep -q "Let's Encrypt" && echo "OK" || echo "FAIL")
checkStatus "$httpsResponse" "HTTPS connectivity to ${mailcowHostname}"
checkStatus "$certChain" "Certificate issued by Let's Encrypt"
else
warnStatus "MAILCOW_HOSTNAME not found, skipping HTTPS checks"
fi
}
checkMailcowConfig() {
printSection "3. Mailcow Configuration"
if [ -f /var/deploy/mailcow/mailcow.conf ]; then
checkStatus "OK" "mailcow.conf exists"
local skipLe
local useWatchdog
local httpRedirect
skipLe=$(grep "^SKIP_LETS_ENCRYPT=" /var/deploy/mailcow/mailcow.conf | cut -d= -f2)
if [ "$skipLe" = "n" ]; then
checkStatus "OK" "Let's Encrypt enabled in mailcow"
else
warnStatus "Let's Encrypt disabled in mailcow (expected with Traefik)"
fi
useWatchdog=$(grep "^USE_WATCHDOG=" /var/deploy/mailcow/mailcow.conf | cut -d= -f2)
if [ "$useWatchdog" = "y" ]; then
checkStatus "OK" "Watchdog enabled"
else
warnStatus "Watchdog disabled"
fi
httpRedirect=$(grep "^HTTP_REDIRECT=" /var/deploy/mailcow/mailcow.conf | cut -d= -f2)
if [ "$httpRedirect" = "y" ]; then
checkStatus "OK" "HTTP to HTTPS redirect enabled"
else
warnStatus "HTTP to HTTPS redirect disabled"
fi
else
checkStatus "FAIL" "mailcow.conf not found"
fi
local certdumperStatus
certdumperStatus=$(cd /var/deploy/mailcow && docker compose ps --format json 2>/dev/null | jq -r "select(.Service==\"certdumper\") | .State" | head -1)
if [ "$certdumperStatus" = "running" ]; then
checkStatus "OK" "Certdumper service running (syncs Traefik certs to mailcow)"
else
checkStatus "FAIL" "Certdumper service not running"
fi
}
checkAcmeLogs() {
printSection "4. SSL Challenge Status"
local skipLe
skipLe=$(grep "^SKIP_LETS_ENCRYPT=" /var/deploy/mailcow/mailcow.conf | cut -d= -f2)
if [ "$skipLe" = "y" ]; then
checkStatus "OK" "Mailcow ACME disabled (SKIP_LETS_ENCRYPT=y)"
return 0
fi
local acmeErrors
acmeErrors=$(cd /var/deploy/mailcow && docker compose logs acme-mailcow --tail 50 2>&1 | grep -i "HTTP validation failed" | wc -l)
if [ "$acmeErrors" -gt 0 ]; then
warnStatus "Found ${acmeErrors} HTTP validation failures in mailcow ACME logs"
warnStatus "Expected if autodiscover/autoconfig subdomains are handled by Traefik"
else
checkStatus "OK" "No HTTP validation failures in mailcow ACME logs"
fi
}
checkMailSecurity() {
printSection "5. Mail Security (DANE/DNSSEC/MTA-STS)"
if [ -z "$mailcowHostname" ]; then
warnStatus "MAILCOW_HOSTNAME not found, skipping mail security checks"
return 1
fi
local domainPart
domainPart=$(echo "$mailcowHostname" | cut -d. -f2-)
# Check DNSSEC.
echo -e "${yellow}DNSSEC Status:${noColor}"
local dsRecords
local dnskeyRecords
dsRecords=$(dig +short DS "$domainPart" 2>/dev/null | wc -l)
dnskeyRecords=$(dig +short DNSKEY "$domainPart" 2>/dev/null | wc -l)
if [ "$dsRecords" -gt 0 ] && [ "$dnskeyRecords" -gt 0 ]; then
checkStatus "OK" "DNSSEC enabled (${dsRecords} DS, ${dnskeyRecords} DNSKEY records)"
else
warnStatus "DNSSEC not fully active (DS: ${dsRecords}, DNSKEY: ${dnskeyRecords})"
fi
# Check TLSA/DANE records.
echo -e "${yellow}TLSA/DANE Records:${noColor}"
local tlsa25
local tlsa465
local tlsa587
tlsa25=$(dig +short TLSA "_25._tcp.${mailcowHostname}" 2>/dev/null | wc -l)
tlsa465=$(dig +short TLSA "_465._tcp.${mailcowHostname}" 2>/dev/null | wc -l)
tlsa587=$(dig +short TLSA "_587._tcp.${mailcowHostname}" 2>/dev/null | wc -l)
if [ "$tlsa25" -gt 0 ]; then
checkStatus "OK" "TLSA record for port 25 (SMTP)"
else
warnStatus "TLSA record missing for port 25"
fi
if [ "$tlsa465" -gt 0 ]; then
checkStatus "OK" "TLSA record for port 465 (SMTPS)"
else
warnStatus "TLSA record missing for port 465"
fi
if [ "$tlsa587" -gt 0 ]; then
checkStatus "OK" "TLSA record for port 587 (Submission)"
else
warnStatus "TLSA record missing for port 587"
fi
# Check MTA-STS.
echo -e "${yellow}MTA-STS:${noColor}"
local mtaStsDns
local mtaStsPolicy
mtaStsDns=$(dig +short TXT "_mta-sts.${domainPart}" 2>/dev/null | grep -q "STSv1" && echo "OK" || echo "FAIL")
mtaStsPolicy=$(curl -sk "https://mta-sts.${domainPart}/.well-known/mta-sts.txt" 2>/dev/null | grep -q "version: STSv1" && echo "OK" || echo "FAIL")
checkStatus "$mtaStsDns" "MTA-STS DNS record"
checkStatus "$mtaStsPolicy" "MTA-STS policy file accessible"
# Check TLS-RPT.
echo -e "${yellow}TLS-RPT:${noColor}"
local tlsRpt
tlsRpt=$(dig +short TXT "_smtp._tls.${domainPart}" 2>/dev/null | grep -q "TLSRPTv1" && echo "OK" || echo "FAIL")
checkStatus "$tlsRpt" "TLS-RPT DNS record"
# Check DKIM.
echo -e "${yellow}DKIM:${noColor}"
local dkimRecord
dkimRecord=$(dig +short TXT "default._domainkey.${domainPart}" 2>/dev/null | grep -q "DKIM1" && echo "OK" || echo "FAIL")
checkStatus "$dkimRecord" "DKIM DNS record"
# Check SPF.
echo -e "${yellow}SPF:${noColor}"
local spfRecord
spfRecord=$(dig +short TXT "$domainPart" 2>/dev/null | grep -q "spf1" && echo "OK" || echo "FAIL")
checkStatus "$spfRecord" "SPF DNS record"
# Check DMARC.
echo -e "${yellow}DMARC:${noColor}"
local dmarcRecord
dmarcRecord=$(dig +short TXT "_dmarc.${domainPart}" 2>/dev/null | grep -q "DMARC1" && echo "OK" || echo "FAIL")
checkStatus "$dmarcRecord" "DMARC DNS record"
# Check reverse DNS (PTR).
echo -e "${yellow}Reverse DNS (PTR):${noColor}"
local ipv4Addr
local ipv6Addr
ipv4Addr=$(dig +short A "$mailcowHostname" 2>/dev/null | head -1)
ipv6Addr=$(dig +short AAAA "$mailcowHostname" 2>/dev/null | head -1)
if [ -n "$ipv4Addr" ]; then
local ptr4
ptr4=$(dig +short -x "$ipv4Addr" 2>/dev/null)
if echo "$ptr4" | grep -q "$mailcowHostname"; then
checkStatus "OK" "IPv4 PTR record points to ${mailcowHostname}"
else
warnStatus "IPv4 PTR record: ${ptr4:-not found}"
fi
fi
if [ -n "$ipv6Addr" ]; then
local ptr6
ptr6=$(dig +short -x "$ipv6Addr" 2>/dev/null)
if echo "$ptr6" | grep -q "$mailcowHostname"; then
checkStatus "OK" "IPv6 PTR record points to ${mailcowHostname}"
else
warnStatus "IPv6 PTR record: ${ptr6:-not found}"
fi
fi
}
printSummary() {
printSection "Summary"
echo -e "${green}${noColor} Services: Most services are running"
echo -e "${green}${noColor} SSL/TLS: Certificates are valid and properly configured"
echo -e "${green}${noColor} Mailcow: Configuration appears correct"
echo ""
echo -e "${blue}Health check completed.${noColor}"
}
echo -e "${blue}========================================${noColor}"
echo -e "${blue} Comprehensive Diagnostics Report${noColor}"
echo -e "${blue}========================================${noColor}"
echo -e "${yellow}Checking Docker and Docker Compose installation...${noColor}"
if command -v docker >/dev/null 2>&1; then
echo -e "${GREEN}Docker is installed: $(docker --version)${NC}"
echo -e "${green}Docker is installed: $(docker --version)${noColor}"
else
echo -e "${RED}Docker is not installed!${NC}"
echo -e "${red}Docker is not installed.${noColor}"
exit 1
fi
if docker compose version >/dev/null 2>&1; then
echo -e "${GREEN}Docker Compose plugin is installed: $(docker compose version)${NC}"
echo -e "${green}Docker Compose plugin is installed: $(docker compose version)${noColor}"
else
echo -e "${RED}Docker Compose plugin is not installed!${NC}"
echo -e "${red}Docker Compose plugin is not installed.${noColor}"
exit 1
fi
# Check system resources
echo -e "${YELLOW}Checking system resources...${NC}"
echo -e "${YELLOW}CPU:${NC} $(grep -c processor /proc/cpuinfo) cores"
echo -e "${YELLOW}Memory:${NC} $(free -h | grep Mem | awk '{print $2}')"
echo -e "${YELLOW}Disk space:${NC} $(df -h / | awk 'NR==2 {print $2}')"
# Domain configuration from .env file
if [ -f "./core/.env" ]; then
source ./core/.env
echo -e "${YELLOW}Domain configuration:${NC} ${DOMAIN}"
else
echo -e "${RED}Core .env file not found!${NC}"
DOMAIN="example.com"
if ! command -v jq >/dev/null 2>&1; then
warnStatus "jq not found, some checks will be skipped"
fi
# Run specific checks
check_traefik
echo ""
check_gitlab
echo ""
check_databases
echo ""
check_all_services
echo -e "${yellow}Checking system resources...${noColor}"
echo -e "${yellow}CPU:${noColor} $(grep -c processor /proc/cpuinfo) cores"
echo -e "${yellow}Memory:${noColor} $(free -h | grep Mem | awk '{print $2}')"
echo -e "${yellow}Disk space:${noColor} $(df -h / | awk 'NR==2 {print $2}')"
echo -e "${GREEN}Diagnostic checks completed.${NC}"
echo -e "${YELLOW}For detailed logs, run: docker logs <container_name>${NC}"
loadCoreDomain
loadMailcowHostname
printSection "Core Service Diagnostics"
checkTraefik
echo ""
checkForgejo
echo ""
checkDatabases
echo ""
checkNextcloud
echo ""
checkAllServices
echo ""
checkMailcowServices
checkSslAndCerts
checkMailcowConfig
checkAcmeLogs
checkMailSecurity
printSummary

425
drupal/README.md Normal file
View file

@ -0,0 +1,425 @@
# Headless Drupal Stack
Drupal 11 backend for Next.js frontend with JSON:API, OAuth authentication, and Redis caching.
## Quick Start
### 1. Setup Shell Aliases (Optional but Recommended)
```bash
cd /var/deploy/drupal
./setup-aliases.sh
source ~/.bash-aliases
```
This creates convenient shortcuts in `~/.bash-aliases`:
- `ddrush` - Run Drush commands
- `dcomposer` - Run Composer commands
- `dphp` - Run PHP commands
- `dshell` - Open bash shell in container
- `dlogs` - View PHP-FPM logs (follow mode)
- `dredis` - Access Redis CLI
### 2. Start Services
```bash
cd /var/deploy
docker compose -f drupal/docker-compose.yml up -d
```
### 3. Install Headless Modules
```bash
cd /var/deploy/drupal
./install-headless-modules.sh
```
### 4. Enable Core Modules
```bash
# With alias
ddrush en next jsonapi_extras simple_oauth consumers pathauto cors decoupled_router -y
# Without alias
docker exec drupal-fpm bash -c "cd /opt/drupal && drush en next jsonapi_extras simple_oauth consumers pathauto cors decoupled_router -y"
```
### 5. Generate OAuth Keys
```bash
docker exec drupal-fpm bash -c 'cd /opt/drupal && mkdir -p oauth/keys && openssl genrsa -out oauth/keys/private.key 2048 && openssl rsa -in oauth/keys/private.key -pubout -out oauth/keys/public.key && chown www-data:www-data oauth/keys/private.key oauth/keys/public.key && chmod 640 oauth/keys/private.key oauth/keys/public.key'
```
(PHP-FPM runs as www-data; keys must be readable by it.)
Add to `drupal/root/web/sites/default/settings.php`:
```php
$settings['simple_oauth.keys'] = [
'public' => '/opt/drupal/oauth/keys/public.key',
'private' => '/opt/drupal/oauth/keys/private.key',
];
```
### 6. Configure Redis Cache
Add to `drupal/root/web/sites/default/settings.php`:
```php
// Redis Configuration.
$settings['redis.connection']['interface'] = 'PhpRedis';
$settings['redis.connection']['host'] = 'drupal-redis';
$settings['redis.connection']['port'] = 6379;
$settings['cache']['default'] = 'cache.backend.redis';
$settings['cache']['bins']['bootstrap'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['discovery'] = 'cache.backend.chainedfast';
$settings['cache']['bins']['config'] = 'cache.backend.chainedfast';
```
## Stack Components
### Containers
- **drupal-reverse-proxy**: NGINX reverse proxy (port 80)
- **drupal-fpm**: PHP 8.4 FPM with Drupal 11
- **drupal-redis**: Redis 7 cache backend
### Key Modules
- `drupal/next` - Next.js integration
- `drupal/jsonapi_extras` - JSON:API enhancements
- `drupal/simple_oauth` - OAuth 2.0 authentication
- `drupal/pathauto` - URL aliases
- `drupal/cors` - CORS headers
- `drupal/decoupled_router` - Route resolution
## Common Commands
### Shell Aliases (Recommended)
After running `./setup-aliases.sh`:
```bash
# Composer commands
dcomposer require drupal/module_name
dcomposer update
dcomposer show
# Drush commands
ddrush cr # Clear cache
ddrush en module_name -y # Enable module
ddrush updb -y # Database updates
ddrush cex -y # Export configuration
ddrush cim -y # Import configuration
ddrush status # Site status
# PHP commands
dphp -v # PHP version
dphp -m # PHP modules
dphp -i # PHP info
# Container access
dshell # Open bash shell
dlogs # View logs
dredis ping # Test Redis
```
### Without Aliases (Full Commands)
#### Composer
```bash
# Install module
docker exec drupal-fpm bash -c "cd /opt/drupal && composer require drupal/module_name"
# Update dependencies
docker exec drupal-fpm bash -c "cd /opt/drupal && composer update"
# Show installed packages
docker exec drupal-fpm bash -c "cd /opt/drupal && composer show"
```
#### Drush
```bash
# Clear cache
docker exec drupal-fpm bash -c "cd /opt/drupal && drush cr"
# Enable module
docker exec drupal-fpm bash -c "cd /opt/drupal && drush en module_name -y"
# Database updates
docker exec drupal-fpm bash -c "cd /opt/drupal && drush updb -y"
# Export configuration
docker exec drupal-fpm bash -c "cd /opt/drupal && drush cex -y"
# Import configuration
docker exec drupal-fpm bash -c "cd /opt/drupal && drush cim -y"
# Site status
docker exec drupal-fpm bash -c "cd /opt/drupal && drush status"
```
### Container Management
```bash
# View logs
docker logs drupal-fpm
docker logs drupal-reverse-proxy
# Access container shell
docker exec -it drupal-fpm bash
# Restart services
docker compose -f drupal/docker-compose.yml restart
# Rebuild container
docker compose -f drupal/docker-compose.yml up -d --build
```
### Redis
```bash
# With alias
dredis ping # Test connection
dredis monitor # Monitor Redis
dredis keys '*' # Check keys
dredis flushall # Clear cache
# Without alias
docker exec drupal-redis redis-cli ping
docker exec drupal-redis redis-cli monitor
docker exec drupal-redis redis-cli keys '*'
docker exec drupal-redis redis-cli flushall
```
## Configuration
### Next.js Site Configuration
1. Navigate to `/admin/config/services/next`
2. Add Next.js site:
- Label: Production Frontend
- Base URL: `https://nasarek.dev`
- Preview URL: `https://nasarek.dev/api/preview`
- Preview Secret: Generate secure random string
3. The env page (`/admin/config/services/next/sites/{site_id}/env`) shows a template; `DRUPAL_CLIENT_ID` and `DRUPAL_CLIENT_SECRET` will say "Retrieve this from /admin/config/services/consumer" — get the actual Client ID and secret from the OAuth consumer (see OAuth Consumer section below).
**Preview secret expired:** If you see "The provided secret has expired", increase the secret lifetime (default is 30 seconds):
- **Via UI:** `/admin/config/services/next/settings` → Preview URL generator → Simple OAuth → Secret expiration time → set to 300 (5 min) or 3600 (1 h)
- **Via Drush:** `ddrush cset next.settings preview_url_generator_configuration.secret_expiration 300 -y`
### CORS Configuration
1. Navigate to `/admin/config/services/cors`
2. Add configuration:
- Enabled: ✓
- Domains: `https://nasarek.dev|*`
- Methods: `GET|POST|OPTIONS`
- Headers: `Authorization|Content-Type|*`
- Credentials: ✓
### OAuth Consumer
**1. Create a dynamic scope** (required; Drupal rejects token requests without a valid scope):
- Navigate to `/admin/config/people/simple_oauth/oauth2_scope/dynamic/add`
- Role: Authenticated user (or a dedicated role)
- Granularity: Role
- Grant Types: Enable **Client credentials**
- Description: e.g. Next.js Frontend
- Machine-readable name: e.g. `nextjs_frontend`
- Save
**2. Create or edit a consumer:**
- Navigate to `/admin/config/services/consumer` (or add at `/admin/config/services/consumer/add`)
- Label: e.g. Next.js Frontend (or use existing default_consumer)
- **Client ID**: Use the **Client ID** column value from the consumer list (e.g. `lcXmAvaCtkjDhgKsLeAfUucOibVwUxKXtla8b_4CC4w`), **not** the UUID or label
- **Secret**: Not shown in the list; edit the consumer to generate or regenerate it
- Grant Types: Enable **Client credentials**
- Scopes: Select the scope created in step 1 (e.g. `nextjs_frontend`)
- Redirect URI: `https://nasarek.dev`
- Is 3rd party?: Leave **unchecked** (Next.js is your own frontend)
- Save
**3. Add to `nextjs/.env.local`:**
```
DRUPAL_CLIENT_ID=<Client ID from the consumer list>
DRUPAL_CLIENT_SECRET=<Secret from the consumer edit form>
DRUPAL_OAUTH_SCOPE=<machine name from step 1, e.g. nextjs_frontend>
```
**4. Recreate Next.js container** (env vars are read at container start):
```bash
cd /var/deploy/drupal && docker compose -f docker-compose.yml up -d nextjs --force-recreate
```
**Verify OAuth:**
```bash
cd /var/deploy/drupal && docker compose -f docker-compose.yml exec nextjs node -e "
const b=process.env.NEXT_PUBLIC_DRUPAL_BASE_URL,i=process.env.DRUPAL_CLIENT_ID,s=process.env.DRUPAL_CLIENT_SECRET,scope=process.env.DRUPAL_OAUTH_SCOPE;
const body=new URLSearchParams({grant_type:'client_credentials',client_id:i,client_secret:s});
if(scope) body.set('scope',scope);
fetch(b+'/oauth/token',{method:'POST',body}).then(r=>r.json()).then(j=>console.log(j.access_token?'OK':JSON.stringify(j)));
"
```
### Next.js Environment Variables
Required in `nextjs/.env.local` (loaded via docker-compose `env_file`):
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_DRUPAL_BASE_URL` | Yes | Drupal JSON:API base URL (e.g. `https://cms.nasarek.dev`) |
| `DRUPAL_CLIENT_ID` | Yes* | OAuth consumer Client ID (from consumer list, not UUID) |
| `DRUPAL_CLIENT_SECRET` | Yes* | OAuth consumer secret from edit form |
| `DRUPAL_OAUTH_SCOPE` | Yes* | OAuth scope machine name (e.g. `nextjs_frontend`) |
| `DRUPAL_PREVIEW_SECRET` | No | For draft/preview mode webhook |
| `DRUPAL_REVALIDATE_SECRET` | No | For on-demand revalidation webhook |
\* Without OAuth credentials, JSON:API requests run as anonymous; protected content will return "Access denied". Use `--force-recreate` after changing env vars.
### JSON:API Configuration
1. Navigate to `/admin/config/services/jsonapi/extras`
2. Configure resource types:
- Disable unwanted resources
- Rename fields for frontend convenience
- Configure includes and sparse fieldsets
## JSON:API Endpoints
### Examples
```bash
# Get all articles
curl https://cms.nasarek.dev/jsonapi/node/article
# Get specific article
curl https://cms.nasarek.dev/jsonapi/node/article/{uuid}
# Filter by status
curl https://cms.nasarek.dev/jsonapi/node/article?filter[status]=1
# Include related entities
curl https://cms.nasarek.dev/jsonapi/node/article?include=field_image,uid
# Sparse fieldsets
curl https://cms.nasarek.dev/jsonapi/node/article?fields[node--article]=title,body,created
# Sort and paginate
curl https://cms.nasarek.dev/jsonapi/node/article?sort=-created&page[limit]=10&page[offset]=0
```
### With Authentication
```bash
# Get access token (client credentials - used by next-drupal)
curl -X POST https://cms.nasarek.dev/oauth/token \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_UUID" \
-d "client_secret=YOUR_SECRET"
# Use token
curl https://cms.nasarek.dev/jsonapi/node/article \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
## Debugging
### Enable Xdebug
Xdebug is pre-configured and enabled:
- Mode: debug
- Client host: host.docker.internal
- Client port: 9000
- IDE key: VSCODE
- Log: `/var/log/xdebug.log`
### View Logs
```bash
# PHP-FPM logs
docker logs drupal-fpm
# NGINX logs
docker logs drupal-reverse-proxy
# Xdebug log
docker exec drupal-fpm tail -f /var/log/xdebug.log
```
### Check PHP Configuration
```bash
# PHP version
docker exec drupal-fpm php -v
# PHP modules
docker exec drupal-fpm php -m
# PHP info
docker exec drupal-fpm php -i
# OPcache status
docker exec drupal-fpm php -r "echo json_encode(opcache_get_status(), JSON_PRETTY_PRINT);"
```
## Troubleshooting
### Module not found
```bash
# Check if module is installed
dcomposer show drupal/module_name
# Install if missing
dcomposer require drupal/module_name
```
### Clear all caches
```bash
# Drupal cache
ddrush cr
# Redis cache
docker exec drupal-redis redis-cli flushall
# OPcache (restart PHP-FPM)
docker compose -f drupal/docker-compose.yml restart drupal-fpm
```
### Permissions issues
```bash
# Fix file permissions
docker exec drupal-fpm chown -R www-data:www-data /opt/drupal/web/sites/default/files
# Settings.php permissions
docker exec drupal-fpm chmod 444 /opt/drupal/web/sites/default/settings.php
```
## File Structure
```
drupal/
├── docker-compose.yml # Service definitions
├── install-headless-modules.sh # Module installation script
├── drupal/
│ ├── Dockerfile # PHP-FPM image
│ └── root/ # Drupal codebase (mounted volume)
│ ├── composer.json
│ ├── web/ # Document root
│ │ ├── core/
│ │ ├── modules/
│ │ │ ├── contrib/
│ │ │ └── custom/
│ │ ├── themes/
│ │ └── sites/
│ └── oauth/
│ └── keys/ # OAuth RSA keys
└── nginx/
├── Dockerfile
└── nginx.conf.template # NGINX configuration
```
## Development Workflow
1. Make changes to code in `drupal/root/`
2. Clear cache: `ddrush cr` (or `docker exec drupal-fpm bash -c "cd /opt/drupal && drush cr"`)
3. Export config: `ddrush cex -y` (or `docker exec drupal-fpm bash -c "cd /opt/drupal && drush cex -y"`)
4. Commit changes to git
5. Deploy: Pull changes and run `ddrush cim -y` on production
## Production Checklist
- [ ] Set `opcache.validate_timestamps=0` in production
- [ ] Configure Redis cache
- [ ] Generate OAuth keys
- [ ] Configure CORS restrictively
- [ ] Set up content revalidation webhooks
- [ ] Configure rate limiting
- [ ] Enable HTTPS only
- [ ] Set secure session cookies
- [ ] Configure backup strategy
- [ ] Monitor logs and performance
- [ ] Test JSON:API endpoints with different user roles
## Resources
- [Next.js for Drupal](https://next-drupal.org/)
- [JSON:API Documentation](https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module)
- [Simple OAuth](https://www.drupal.org/project/simple_oauth)
- [Drupal.org](https://www.drupal.org/)

View file

@ -0,0 +1,27 @@
# Development overrides: bind mounts for Next.js hot reload.
# Loaded automatically by Docker Compose. Remove or rename to disable.
services:
nextjs:
build:
context: ./nextjs
dockerfile: Dockerfile
target: development
args:
NEXT_PUBLIC_DRUPAL_BASE_URL: https://cms.${DOMAIN}
volumes:
- ./nextjs:/app
- nextjs-node-modules:/app/node_modules
- nextjs-next-cache:/app/.next
ports:
- "9229:9229"
environment:
- NODE_ENV=production
- HOSTNAME=0.0.0.0
- WATCHPACK_POLLING=false
- NODE_OPTIONS=--inspect=0.0.0.0:9229
volumes:
nextjs-node-modules:
name: drupal-nextjs-node-modules
nextjs-next-cache:
name: drupal-nextjs-next-cache

View file

@ -1,6 +1,7 @@
services:
# Drupal CMS NGINX reverse proxy (cms.nasarek.dev).
nginx:
image: drupal-nginx
image: rnsrk/drupal-nginx
build:
context: ./nginx
dockerfile: Dockerfile
@ -10,7 +11,7 @@ services:
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.drupal-reverse-proxy.rule=Host(`${DOMAIN}`)
- traefik.http.routers.drupal-reverse-proxy.rule=Host(`cms.${DOMAIN}`)
- traefik.http.routers.drupal-reverse-proxy.entrypoints=web,websecure
- traefik.http.routers.drupal-reverse-proxy.middlewares=https-redirect
- traefik.http.routers.drupal-reverse-proxy.tls=true
@ -22,13 +23,14 @@ services:
- traefik
- drupal
# Drupal PHP-FPM backend.
drupal-fpm:
image: drupal-php8-4-fpm-bookworm
image: rnsrk/drupal-php8-4-fpm-bookworm
build:
context: ./drupal
dockerfile: Dockerfile
args:
DRUPAL_VERSION: ${DRUPAL_VERSION:-11.1.6}
DRUPAL_VERSION: ${DRUPAL_VERSION:-11.3.3}
labels:
- traefik.enable=false
container_name: drupal-fpm
@ -40,12 +42,11 @@ services:
- database
- drupal
# Redis cache backend.
redis:
image: redis:7-alpine
container_name: drupal-redis
command: redis-server --loglevel warning
environment:
- OVERC
volumes:
- redis-data:/data
networks:
@ -57,6 +58,37 @@ services:
retries: 5
start_period: 10s
# Next.js frontend (nasarek.dev).
nextjs:
image: rnsrk/nextjs-frontend
build:
context: ./nextjs
dockerfile: Dockerfile
args:
NEXT_PUBLIC_DRUPAL_BASE_URL: https://cms.${DOMAIN}
DRUPAL_CLIENT_ID: ${DRUPAL_CLIENT_ID}
DRUPAL_CLIENT_SECRET: ${DRUPAL_CLIENT_SECRET}
DRUPAL_OAUTH_SCOPE: ${DRUPAL_OAUTH_SCOPE:-}
container_name: nextjs-frontend
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.nextjs-frontend.rule=Host(`${DOMAIN}`)
- traefik.http.routers.nextjs-frontend.entrypoints=web,websecure
- traefik.http.routers.nextjs-frontend.middlewares=https-redirect
- traefik.http.routers.nextjs-frontend.tls=true
- traefik.http.routers.nextjs-frontend.tls.certresolver=le
- traefik.http.services.nextjs-frontend.loadbalancer.server.port=3000
env_file:
- ./nextjs/.env.local
networks:
- traefik
- drupal
depends_on:
- drupal-fpm
restart: unless-stopped
volumes:
redis-data:
name: drupal-redis-data

View file

@ -1,7 +1,8 @@
ARG DRUPAL_VERSION
ARG DRUPAL_VERSION=11.3.3
FROM drupal:${DRUPAL_VERSION}-php8.4-fpm-bookworm
ARG NODE_ENV=production
RUN apt-get update && apt-get install -y \
git \
vim \
@ -14,13 +15,13 @@ RUN set -eux; \
docker-php-ext-install uploadprogress; \
rm -rf /usr/src/php/ext/uploadprogress;
# Install apcu
# Install apcu.
RUN set -eux; \
pecl install apcu;
pecl install apcu; \
docker-php-ext-enable apcu;
# Add php configs
# Configure apcu (extension already loaded by docker-php-ext-enable).
RUN { \
echo 'extension=apcu.so'; \
echo "apc.enable_cli=1"; \
echo "apc.enable=1"; \
echo "apc.shm_size=32M"; \
@ -30,3 +31,82 @@ RUN { \
RUN { \
echo 'output_buffering = on'; \
} >> /usr/local/etc/php/conf.d/zz-drupal-recommended.ini;
# Enable Xdebug in development environment.
RUN if [ "$NODE_ENV" = "development" ]; then \
set -eux; \
pecl install xdebug; \
docker-php-ext-enable xdebug; \
fi;
# Configure Xdebug in development environment.
# Configure xdebug (extension already loaded by docker-php-ext-enable).
RUN if [ "$NODE_ENV" = "development" ]; then \
{ \
echo 'xdebug.mode=debug'; \
echo 'xdebug.start_with_request=yes'; \
echo 'xdebug.client_host=host.docker.internal'; \
echo 'xdebug.client_port=9000'; \
echo 'xdebug.idekey=VSCODE'; \
echo 'xdebug.log=/var/log/xdebug.log'; \
} >> /usr/local/etc/php/conf.d/zz-xdebug-custom.ini; \
fi
# Install and enable opcache.
RUN set -eux; \
docker-php-ext-install opcache; \
docker-php-ext-enable opcache
# Configure opcache: dev (revalidate on) vs production (revalidate off).
RUN if [ "$NODE_ENV" = "development" ]; then \
{ \
echo 'opcache.enable=1'; \
echo 'opcache.enable_cli=1'; \
echo 'opcache.memory_consumption=128'; \
echo 'opcache.interned_strings_buffer=16'; \
echo 'opcache.max_accelerated_files=10000'; \
echo 'opcache.save_comments=1'; \
echo 'opcache.validate_timestamps=1'; \
echo 'opcache.revalidate_freq=0'; \
echo 'opcache.fast_shutdown=1'; \
} > /usr/local/etc/php/conf.d/zz-opcache-custom.ini; \
else \
{ \
echo 'opcache.enable=1'; \
echo 'opcache.enable_cli=0'; \
echo 'opcache.memory_consumption=256'; \
echo 'opcache.interned_strings_buffer=32'; \
echo 'opcache.max_accelerated_files=20000'; \
echo 'opcache.save_comments=1'; \
echo 'opcache.validate_timestamps=0'; \
echo 'opcache.fast_shutdown=1'; \
} > /usr/local/etc/php/conf.d/zz-opcache-custom.ini; \
fi
# Install Redis PHP extension.
RUN set -eux; \
pecl install redis; \
docker-php-ext-enable redis;
# Install Node.js via NVM for frontend tooling.
ENV NVM_DIR=/root/.nvm
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash \
&& . "$NVM_DIR/nvm.sh" \
&& nvm install --lts \
&& nvm alias default 'lts/*' \
&& nvm use default \
&& node -v \
&& npm -v
# Production PHP settings (disable display_errors for clean JSON:API responses).
RUN { \
echo 'display_errors = Off'; \
echo 'log_errors = On'; \
echo 'error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT'; \
} >> /usr/local/etc/php/conf.d/zz-production.ini
# Note: Drupal modules should be installed via composer in the project directory.
# The /opt/drupal directory is mounted as a volume, so composer require.
# commands here would be lost on container start.
# Install modules by running:
# docker exec drupal-fpm bash -c "cd /opt/drupal && composer require drupal/module_name"

View file

@ -0,0 +1,65 @@
#!/bin/bash
set -e
echo "Installing Headless Drupal modules..."
# Check if aliases are available.
if command -v dcomposer &> /dev/null; then
COMPOSER_CMD="dcomposer"
DRUSH_CMD="ddrush"
else
COMPOSER_CMD="docker exec drupal-fpm composer"
DRUSH_CMD="docker exec drupal-fpm drush"
fi
# Core headless modules.
echo "→ Installing core headless modules..."
$COMPOSER_CMD require \
drupal/next \
drupal/jsonapi_extras \
drupal/simple_oauth \
drupal/pathauto \
# Additional JSON:API enhancements.
echo "→ Installing JSON:API enhancement modules..."
$COMPOSER_CMD require \
drupal/subrequests \
drupal/decoupled_router \
drupal/consumers \
drupal/jsonapi_menu_items \
drupal/jsonapi_include \
drupal/jsonapi_resources \
drupal/jsonapi_menu_items
# Content and media.
echo "→ Installing content and media modules..."
$COMPOSER_CMD require \
drupal/metatag \
drupal/redirect \
drupal/field_group
# Performance and caching.
echo "→ Installing performance modules..."
$COMPOSER_CMD require \
drupal/redis
# Development tools.
echo "→ Installing development tools..."
$COMPOSER_CMD require --dev \
drupal/coder \
drupal/devel \
drupal/restui \
drupal/admin_toolbar
# "Advanced" modules.
echo "→ Installing advanced modules..."
$COMPOSER_CMD require \
drupal/pathauto
echo "✓ All modules installed successfully!"
echo ""
echo "Next steps:"
echo "1. Enable modules: $DRUSH_CMD en next jsonapi_extras simple_oauth pathauto -y"
echo "2. Generate OAuth keys: docker exec drupal-fpm bash -c 'cd /opt/drupal && mkdir -p oauth/keys && openssl genrsa -out oauth/keys/private.key 2048 && openssl rsa -in oauth/keys/private.key -pubout -out oauth/keys/public.key && chmod 600 oauth/keys/*.key'"
echo "3. Configure modules via Drupal admin UI"
echo "4. Export configuration: $DRUSH_CMD cex -y"

View file

@ -0,0 +1,7 @@
node_modules
.next
.git
*.md
.env*.local
Dockerfile
.dockerignore

25
drupal/nextjs/.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build
.next
out
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env
.env*.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts

61
drupal/nextjs/Dockerfile Normal file
View file

@ -0,0 +1,61 @@
# Stage 0: Development (bind-mount source, run next dev).
FROM node:22-alpine AS development
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NODE_ENV=development
# WATCHPACK_POLLING helps with bind mounts on some file systems.
ENV WATCHPACK_POLLING=true
ENTRYPOINT ["sh", "-c", "[ -d node_modules/.bin ] || npm install; exec npm run dev"]
# Stage 1: Install dependencies.
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci || npm install
# Stage 2: Build the application.
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build arguments for environment variables needed at build time.
ARG NEXT_PUBLIC_DRUPAL_BASE_URL
ARG DRUPAL_CLIENT_ID
ARG DRUPAL_CLIENT_SECRET
ARG DRUPAL_OAUTH_SCOPE
ENV NEXT_PUBLIC_DRUPAL_BASE_URL=${NEXT_PUBLIC_DRUPAL_BASE_URL}
ENV DRUPAL_CLIENT_ID=${DRUPAL_CLIENT_ID}
ENV DRUPAL_CLIENT_SECRET=${DRUPAL_CLIENT_SECRET}
ENV DRUPAL_OAUTH_SCOPE=${DRUPAL_OAUTH_SCOPE}
RUN npm run build
# Stage 3: Production runner.
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone output.
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View file

@ -0,0 +1,89 @@
import { drupal } from "@/lib/drupal"
import type { DrupalNode } from "@/lib/types"
import { NodeArticle } from "@/components/node-article"
import { notFound } from "next/navigation"
import type { Metadata } from "next"
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
interface NodePageProps {
params: Promise<{
slug: string[]
}>
}
// Render dynamically at runtime (not at build time).
export const dynamic = "force-dynamic"
export const revalidate = 60
export async function generateMetadata({
params,
}: NodePageProps): Promise<Metadata> {
if (!drupalBaseUrl) return {}
const { slug } = await params
const path = drupal.constructPathFromSegment(slug)
try {
const translatedPath = await drupal.translatePath(path, { withAuth: true })
if (!translatedPath?.jsonapi?.resourceName || !translatedPath?.entity?.uuid) {
return {}
}
const node = await drupal.getResource<DrupalNode>(
translatedPath.jsonapi.resourceName,
translatedPath.entity.uuid,
{
withAuth: true,
params: {
"fields[node--article]": "title",
"fields[node--page]": "title",
"fields[node--about]": "title",
},
}
)
return {
title: node?.title,
}
} catch {
return {}
}
}
export default async function NodePage({ params }: NodePageProps) {
if (!drupalBaseUrl) notFound()
const { slug } = await params
const path = drupal.constructPathFromSegment(slug)
try {
const translatedPath = await drupal.translatePath(path, { withAuth: true })
if (!translatedPath?.jsonapi?.resourceName || !translatedPath?.entity?.uuid) {
notFound()
}
const type = translatedPath.jsonapi.resourceName
const node = await drupal.getResource<DrupalNode>(
type,
translatedPath.entity.uuid,
{
withAuth: true,
params: {
include: "uid",
},
}
)
if (!node || !node.status) {
notFound()
}
return <NodeArticle node={node} />
} catch {
notFound()
}
}

View file

@ -0,0 +1,6 @@
import { disableDraftMode } from "next-drupal/draft"
import type { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
return disableDraftMode()
}

View file

@ -0,0 +1,7 @@
import { drupal } from "@/lib/drupal"
import { enableDraftMode } from "next-drupal/draft"
import type { NextRequest } from "next/server"
export async function GET(request: NextRequest): Promise<Response | never> {
return enableDraftMode(request, drupal)
}

View file

@ -0,0 +1,9 @@
import { enableDraftMode } from "next-drupal/draft"
import { drupal } from "@/lib/drupal"
import { NextRequest } from "next/server"
export const dynamic = "force-dynamic"
export async function GET(request: NextRequest) {
return enableDraftMode(request, drupal)
}

View file

@ -0,0 +1,35 @@
import { revalidatePath } from "next/cache"
import { NextRequest } from "next/server"
async function handler(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const secret = searchParams.get("secret")
const path = searchParams.get("path")
// Validate the revalidation secret.
if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) {
return new Response("Invalid secret.", { status: 401 })
}
if (!path) {
return new Response("Missing path.", { status: 400 })
}
try {
revalidatePath(path)
return new Response(
JSON.stringify({ revalidated: true, now: Date.now() }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
)
} catch (error) {
return new Response(
JSON.stringify({ message: "Error revalidating.", error }),
{ status: 500 }
)
}
}
export { handler as GET, handler as POST }

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,307 @@
@import "tailwindcss";
:root {
--accent: var(--color-emerald-600);
--accent-hex: #e11d48;
--fluid-hero: clamp(1.75rem, 4vw + 1rem, 3.75rem);
--fluid-hero-desc: clamp(1rem, 1.5vw + 0.75rem, 1.25rem);
--fluid-section-title: clamp(1.5rem, 3vw + 0.75rem, 1.875rem);
/* Fade-in on load: About starts when hero title animation ends (~2.1s), Services after About. */
--fade-about-delay: 2.1s;
--fade-about-duration: 1.2s;
--fade-services-delay: 2.5s;
--fade-services-duration: 1.2s;
}
/* Footer link icons: tint to emerald on link hover/focus (icons are img/SVG with fixed fill). */
.group:hover .footer-icon-hover-emerald,
.group:focus-visible .footer-icon-hover-emerald {
filter: brightness(0) saturate(100%) invert(48%) sepia(79%) saturate(2476%) hue-rotate(130deg) brightness(95%) contrast(101%);
}
@layer base {
a {
@apply transition-colors duration-200 ease-out;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-fade-in-on-load {
animation: fade-in var(--fade-about-duration) ease-in-out var(--fade-about-delay) both;
}
.animate-fade-in-on-load-delayed {
animation: fade-in var(--fade-services-duration) ease-in-out var(--fade-services-delay) both;
}
.animate-delay-100 {
animation-delay: 100ms;
}
.animate-delay-200 {
animation-delay: 200ms;
}
.animate-delay-300 {
animation-delay: 300ms;
}
.animate-marquee {
animation: marquee 30s linear infinite;
}
.animate-marquee-slow {
animation: marquee 60s linear infinite;
}
.home-clients-band {
mask-image: linear-gradient(
to right,
transparent 0%,
black 8%,
black 92%,
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
black 8%,
black 92%,
transparent 100%
);
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-spin-slow {
animation: spin 8s linear infinite;
}
.animate-spin-once {
animation: spin 0.5s ease-in-out 1 forwards;
}
@keyframes coin-spin {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(720deg);
}
}
.animate-coin-spin {
animation: coin-spin 0.6s ease-in-out 1 forwards;
}
/* Hero title letter animations. */
@keyframes letter-from-down {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes letter-from-up {
from {
opacity: 0;
transform: translateY(-100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes letter-b-exit {
0% {
opacity: 0;
transform: translateY(-100%);
}
20% {
opacity: 1;
transform: translateY(0);
}
50% {
opacity: 1;
transform: translateY(0);
}
51% {
opacity: 0;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(0);
}
}
@keyframes letter-o-in-out {
0% {
opacity: 0;
transform: translateY(-100%);
}
20% {
opacity: 1;
transform: translateY(0);
}
70% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-100%);
}
}
@keyframes letter-e-fade-in {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* t: appears with o (Robot), stays, then slides right as r appears. */
@keyframes letter-t-slide {
0% {
opacity: 0;
transform: translateY(-100%) translateX(-0.7ch);
}
20% {
opacity: 1;
transform: translateY(-0.45em) translateX(-0.7ch);
}
55% {
opacity: 1;
transform: translateY(-0.45em) translateX(-0.7ch);
}
100% {
opacity: 1;
transform: translateY(-0.45em) translateX(2px);
}
}
.animate-letter-from-down {
animation: letter-from-down 0.5s ease-out both;
}
.animate-letter-from-up {
animation: letter-from-up 0.5s ease-out both;
}
.animate-letter-b-exit {
animation: letter-b-exit 0.9s ease-out both;
}
.animate-letter-o-in-out {
animation: letter-o-in-out 1.5s ease-out both;
}
.animate-letter-e-fade-in {
animation: letter-e-fade-in 0.5s ease-out both;
}
.animate-letter-t-slide {
animation: letter-t-slide 0.9s ease-out both;
}
.hero-letter-t {
overflow: visible;
vertical-align: baseline;
}
.hero-letter-r {
position: relative;
}
.hero-letter-r::before {
content: "B";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
animation: letter-b-exit 0.6s ease-out both;
animation-delay: var(--b-delay, 320ms);
}
.hero-letter-e {
position: relative;
padding-right: 1px;
padding-left: 1px;
}
.hero-letter-e::before {
content: "o";
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
animation: letter-o-in-out 1.5s ease-out both;
animation-delay: var(--o-delay, 760ms);
}
.layout-footer-section {
margin: 0 auto;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 115 KiB

1517
drupal/nextjs/app/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 574 KiB

View file

@ -0,0 +1,106 @@
import type { Metadata } from "next"
import { ObfuscatedAddress } from "@/components/obfuscated-address"
import { ObfuscatedEmail } from "@/components/obfuscated-email"
import { drupal } from "@/lib/drupal"
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
export const dynamic = "force-dynamic"
export const metadata: Metadata = {
title: "Imprint",
description: "Legal notice and imprint for nasarek.dev",
}
const FALLBACK_TITLE = "Imprint"
const BODY_STYLES = "[&_h2]:mb-4 [&_h2]:mt-8 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:text-slate-900 [&_p]:mb-4 [&_p]:text-slate-700 [&_a]:text-emerald-600 [&_a]:underline hover:[&_a]:text-emerald-500"
/**
* Splits the CMS body HTML at {address} and {email} placeholders and renders
* the obfuscated components in their place so bots cannot harvest the data.
*/
function ImprintBody({ html }: { html: string }) {
const parts = html.split(/(<p>\{(?:address|email)\}<\/p>)/g)
return (
<div className={BODY_STYLES}>
{parts.map((part, i) => {
if (part === "<p>{address}</p>") return <ObfuscatedAddress key={i} />
if (part === "<p>{email}</p>") return <p key={i}><ObfuscatedEmail /></p>
if (!part) return null
return <div key={i} dangerouslySetInnerHTML={{ __html: part }} />
})}
</div>
)
}
async function getImprintPageContent(): Promise<{
title: string
body: string | null
}> {
if (!drupalBaseUrl) {
return {
title: FALLBACK_TITLE,
body: null,
}
}
try {
const translatedPath = await drupal.translatePath("/imprint", {
withAuth: true,
next: { revalidate: 60 },
})
if (!translatedPath?.jsonapi?.resourceName || !translatedPath?.entity?.uuid) {
return { title: FALLBACK_TITLE, body: null }
}
const resourceType = translatedPath.jsonapi.resourceName
const raw = await drupal.getResource(
resourceType,
translatedPath.entity.uuid,
{ withAuth: true, next: { revalidate: 60 }, deserialize: false }
)
const rawData = (raw as { data?: Record<string, unknown> })?.data
if (!rawData) {
return { title: FALLBACK_TITLE, body: null }
}
const title = (rawData.title as string) ?? FALLBACK_TITLE
const bodyObj = rawData.body
const bodyText =
typeof bodyObj === "string"
? bodyObj
: (bodyObj as { processed?: string; value?: string })?.processed ??
(bodyObj as { processed?: string; value?: string })?.value ??
""
return {
title,
body: bodyText || null,
}
} catch (error) {
if ((error as Error).name !== "AbortError") {
console.warn("[Imprint] CMS unreachable:", (error as Error).message)
}
return { title: FALLBACK_TITLE, body: null }
}
}
export default async function ImprintPage() {
const { title, body } = await getImprintPageContent()
return (
<section className="imprint animate-fade-in-on-load pb-10" aria-labelledby="imprint-heading">
<div className="imprint-header mb-8 text-center">
<h1 className="imprint-title mb-3 font-bold tracking-tight text-slate-900"
style={{ fontSize: "var(--fluid-section-title)" }}>
{title}
</h1>
</div>
<div className="imprint-content mx-auto max-w-4xl">
{body && <ImprintBody html={body} />}
</div>
</section>
)
}

View file

@ -0,0 +1,253 @@
import type { Metadata } from "next"
import Image from "next/image"
import { Source_Sans_3 } from "next/font/google"
import {
LayoutGrid,
FileText,
Database,
Scale,
} from "lucide-react"
import { MainNav } from "@/components/main-nav"
import { CookieBanner } from "@/components/cookie-banner"
import "./globals.css"
const sourceSans3 = Source_Sans_3({
subsets: ["latin"],
display: "swap",
})
export const metadata: Metadata = {
title: {
default: "nasarek.dev",
template: "%s | nasarek.dev",
},
description: "Powered by Drupal and Next.js",
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`layout-body ${sourceSans3.className} flex min-h-screen flex-col bg-slate-50 text-slate-900 antialiased`}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-6 focus:top-4 focus:z-100 focus:rounded-md focus:bg-slate-800 focus:px-4 focus:py-2 focus:text-white focus:outline-none focus:ring-2 focus:ring-emerald-400"
>
Skip to main content
</a>
<header className="layout-header sticky top-0 z-50 border-b border-slate-700/50 bg-slate-800/95 backdrop-blur-sm">
<div className="layout-header-inner mx-auto flex max-w-7xl items-center justify-between px-4 py-4 lg:px-6">
<a
href="/"
className="layout-logo flex items-center gap-3 rounded-sm text-2xl font-bold tracking-tight text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800 sm:text-3xl"
aria-label="nasarek.dev home"
>
<Image
src="/icon.svg"
alt=""
width={40}
height={40}
className="size-9 shrink-0 sm:size-10"
/>
Nasarek Data Engineering
</a>
<MainNav />
</div>
</header>
<main id="main-content" className="layout-main flex flex-1 flex-col overflow-x-hidden bg-white">
<div className="layout-main-content mx-auto flex min-h-full w-full max-w-full flex-1 flex-col px-4 py-4 pb-16 lg:px-6">
{children}
</div>
</main>
<footer className="layout-footer mt-12 border-t border-slate-200 bg-slate-50">
<div className="layout-footer-inner mx-auto max-w-7xl px-6 py-12">
<div className="layout-footer-grid grid grid-cols-2 gap-8 text-center sm:text-left lg:grid-cols-4">
<div className="layout-footer-section">
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">
Legal
</h3>
<ul className="space-y-2">
<li className="text-left">
<a
href="/imprint"
className="inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
>
<Scale className="size-4 shrink-0" aria-hidden />
Imprint
</a>
</li>
</ul>
</div>
<div className="layout-footer-section">
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">
Social
</h3>
<ul className="space-y-2">
<li className="text-left">
<a
href="https://github.com/rnsrk"
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
target="_blank"
rel="noopener noreferrer"
>
<Image src="/assets/icons/github.svg" alt="" width={16} height={16} className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200" unoptimized />
GitHub
</a>
</li>
<li className="text-left">
<a
href="https://www.drupal.org/u/rnsrk"
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/assets/icons/drupal.svg"
alt=""
width={16}
height={16}
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
unoptimized
/>
Drupal
</a>
</li>
<li className="text-left">
<a
rel="me"
href="https://fedihum.org/@rnsrk"
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
target="_blank"
>
<Image
src="/assets/icons/mastodon.svg"
alt=""
width={16}
height={16}
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
unoptimized
/>
Mastodon
</a>
</li>
</ul>
</div>
<div className="layout-footer-section">
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">
Powered by
</h3>
<ul className="space-y-2">
<li className="text-left">
<a
href="https://www.drupal.org"
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/assets/icons/drupal.svg"
alt=""
width={16}
height={16}
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
unoptimized
/>
Drupal
</a>
</li>
<li className="text-left">
<a
href="https://nextjs.org"
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/assets/icons/nextjs.svg"
alt=""
width={16}
height={16}
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
unoptimized
/>
Next.js
</a>
</li>
</ul>
</div>
<div className="layout-footer-section">
<h3 className="layout-footer-heading mb-3 text-left text-sm font-semibold uppercase tracking-wider text-slate-500">Media</h3>
<ul className="space-y-2">
<li className="text-left">
<a
href="https://www.youtube.com/watch?v=MGOHzreEU38"
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/assets/icons/youtube.svg"
alt=""
width={16}
height={16}
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
unoptimized
/>
YouTube
</a>
</li>
<li className="text-left">
<a
href="https://open.spotify.com/episode/40v8fdhk4WcXJYu1oIF1oe"
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/assets/icons/spotify.svg"
alt=""
width={16}
height={16}
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
unoptimized
/>
Spotify
</a>
</li>
<li className="text-left">
<a
href="https://zenodo.org/search?q=metadata.creators.person_or_org.name%3A%22Nasarek%2C%20Robert%22&l=list&p=1&s=10&sort=bestmatch"
className="group inline-flex items-center gap-2 text-slate-600 outline-none transition-colors hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/assets/icons/zenodo.svg"
alt=""
width={16}
height={16}
className="footer-icon-hover-emerald size-4 shrink-0 transition-[filter] duration-200"
unoptimized
/>
Zenodo
</a>
</li>
</ul>
</div>
</div>
<div className="layout-footer-copyright mt-10 border-t border-slate-200 pt-8 text-center text-sm text-slate-500">
&copy; {new Date().getFullYear()} nasarek.dev
</div>
</div>
</footer>
<CookieBanner />
</body>
</html>
)
}

View file

@ -0,0 +1,19 @@
export default function NotFound() {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center text-center">
<h1 className="mb-4 text-6xl font-bold text-gray-300">404</h1>
<h2 className="mb-2 text-2xl font-semibold text-gray-700">
Page Not Found
</h2>
<p className="mb-6 text-gray-500">
The page you are looking for does not exist.
</p>
<a
href="/"
className="rounded-lg bg-gray-900 px-6 py-3 text-sm font-medium text-white transition hover:bg-gray-800"
>
Go Home
</a>
</div>
)
}

View file

@ -0,0 +1,30 @@
import { HomeHero } from "@/components/home-hero"
// Force dynamic so HomeAbout fetches at request time (OAuth env vars available in container, not at build).
export const dynamic = "force-dynamic"
import { HomeAbout } from "@/components/home-about"
import { HomeServices } from "@/components/home-services"
import { HomeProjects } from "@/components/home-projects"
import { HomeClients } from "@/components/home-clients"
import { HomeJourneyBackground } from "@/components/home-journey-background"
import { ScrollRevealSection } from "@/components/scroll-reveal-section"
export default function HomePage() {
return (
<HomeJourneyBackground>
<ScrollRevealSection initialVisible>
<HomeHero />
</ScrollRevealSection>
<HomeAbout />
<div className="animate-fade-in-on-load-delayed">
<HomeServices />
</div>
<ScrollRevealSection>
<HomeClients />
</ScrollRevealSection>
<ScrollRevealSection>
<HomeProjects />
</ScrollRevealSection>
</HomeJourneyBackground>
)
}

View file

@ -0,0 +1,65 @@
import type { Metadata } from "next"
import Link from "next/link"
import { FileText, Database, ArrowRight } from "lucide-react"
export const metadata: Metadata = {
title: "Resources",
description: "Browse articles and models on nasarek.dev",
}
export default function ResourcesPage() {
return (
<article className="mx-auto flex min-h-full flex-1 flex-col max-w-7xl">
<header className="mb-12">
<h1 className="mb-4 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
Resources
</h1>
<p className="max-w-2xl text-lg text-slate-600">
Explore my curated collection of articles and models.
</p>
</header>
<section className="grid gap-8 sm:grid-cols-2">
<Link
href="/resources/articles"
className="group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-emerald-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2"
>
<div className="mb-4 inline-flex rounded-lg bg-emerald-100 p-3 text-emerald-600">
<FileText className="size-6" aria-hidden />
</div>
<h2 className="mb-3 text-xl font-semibold text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
Articles
</h2>
<p className="mb-4 text-slate-600">
Written content covering tutorials, guides, and insights. Articles
are published pieces with full text, images, and structured
formatting.
</p>
<span className="inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
Browse articles
<ArrowRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-1" aria-hidden />
</span>
</Link>
<Link
href="/resources/datamodelling"
className="group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-fuchsia-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2"
>
<div className="mb-4 inline-flex rounded-lg bg-fuchsia-100 p-3 text-fuchsia-600">
<Database className="size-6" aria-hidden />
</div>
<h2 className="mb-3 text-xl font-semibold text-fuchsia-600 transition-colors duration-200 ease-out group-hover:text-fuchsia-500">
Datamodelling
</h2>
<p className="mb-4 text-slate-600">
Structured data models and schemas from my projects.
</p>
<span className="inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
Browse models
<ArrowRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-1" aria-hidden />
</span>
</Link>
</section>
</article>
)
}

View file

@ -0,0 +1,78 @@
"use client"
const TEXT = "I'm Robert."
type Direction = "up" | "down" | "r" | "e" | "t"
const LETTER_ANIMS: Record<number, Direction> = {
0: "down", // I
1: "up", // '
2: "down", // m
3: "down", // space - treat as down for delay
4: "r", // R (special)
5: "down", // o
6: "up", // b
7: "e", // e (special: o first, then e)
8: "down", // r
9: "t", // t (special: appears with o, then slides right)
10: "down", // .
}
const DELAYS_MS = [0, 80, 240, 320, 400, 560, 680, 760, 1400, 760, 1560]
export function AnimatedHeroTitle() {
return (
<h1
className="home-hero-title mb-2 font-bold tracking-tight text-slate-900"
style={{ fontSize: "var(--fluid-hero)" }}
>
{TEXT.split("").map((char, i) => {
if (char === " ") {
return <span key={i} className="inline-block" style={{ width: "0.25em" }} aria-hidden />
}
const dir = LETTER_ANIMS[i] ?? "down"
const delay = DELAYS_MS[i] ?? i * 80
const animClass =
dir === "r"
? "hero-letter-r animate-letter-from-up"
: dir === "e"
? "hero-letter-e"
: dir === "t"
? "hero-letter-t animate-letter-t-slide"
: dir === "up"
? "animate-letter-from-up"
: "animate-letter-from-down"
const animDelay =
dir === "r" ? delay + 320 : dir === "e" ? delay + 1050 : delay
const style =
dir === "r"
? { "--b-delay": `${delay}ms`, animationDelay: `${animDelay}ms` } as React.CSSProperties
: dir === "e"
? { "--o-delay": `${delay}ms` } as React.CSSProperties
: { animationDelay: `${animDelay}ms` }
return (
<span
key={i}
className={`inline-block overflow-hidden ${animClass}`}
style={style}
aria-hidden
>
{dir === "e" ? (
<span className="block animate-letter-e-fade-in" style={{ animationDelay: `${animDelay}ms` }}>
e
</span>
) : dir === "r" ? (
"R"
) : dir === "t" ? (
"t"
) : (
char
)}
</span>
)
})}
</h1>
)
}

View file

@ -0,0 +1,58 @@
"use client"
import Image from "next/image"
import { useCallback, useState } from "react"
const IMAGE_POOL = [
"/assets/images/autumn.png",
"/assets/images/kuss.png",
"/assets/images/chaos.png",
"/assets/images/conference.png",
"/assets/images/explaining.png",
"/assets/images/family.png",
"/assets/images/pres_1.png",
"/assets/images/robot.png",
] as const
function pickRandom(exclude?: string): string {
const available = exclude
? IMAGE_POOL.filter((p) => p !== exclude)
: [...IMAGE_POOL]
return available[Math.floor(Math.random() * available.length)]
}
export function AvatarImage({ alt }: { alt: string }) {
const [currentSrc, setCurrentSrc] = useState(() => pickRandom())
const [isSpinning, setIsSpinning] = useState(false)
const handleMouseEnter = useCallback(() => {
setIsSpinning(true)
setCurrentSrc((prev) => pickRandom(prev))
}, [])
const handleAnimationEnd = useCallback(() => {
setIsSpinning(false)
}, [])
return (
<div
className="flex size-20 shrink-0 sm:size-24 [perspective:500px]"
onMouseEnter={handleMouseEnter}
onAnimationEnd={handleAnimationEnd}
>
<div
className={`relative size-full overflow-hidden rounded-full bg-emerald-100 [transform-style:preserve-3d] ${isSpinning ? "animate-coin-spin" : ""}`}
>
<Image
key={currentSrc}
src={currentSrc}
alt={alt}
fill
className="object-cover"
sizes="192px"
quality={95}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,62 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
const CONSENT_COOKIE = "cookie-consent"
const CONSENT_MAX_AGE = 365 * 24 * 60 * 60 // 1 year in seconds
function setConsentCookie() {
document.cookie = `${CONSENT_COOKIE}=accepted; path=/; max-age=${CONSENT_MAX_AGE}; SameSite=Lax`
}
function hasConsent(): boolean {
if (typeof document === "undefined") return false
return document.cookie.includes(`${CONSENT_COOKIE}=accepted`)
}
export function CookieBanner() {
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
if (!hasConsent()) {
setIsVisible(true)
}
}, [])
const handleAccept = () => {
setConsentCookie()
setIsVisible(false)
}
if (!isVisible) return null
return (
<div
role="dialog"
aria-label="Cookie notice"
className="fixed bottom-0 left-0 right-0 z-50 border-t border-slate-200 bg-white/95 p-4 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] backdrop-blur-sm sm:p-6"
>
<div className="mx-auto flex max-w-7xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-slate-600">
This site uses a single cookie to store your consent preference. No
tracking or analytics cookies are used.{" "}
<Link
href="/imprint"
className="font-medium text-emerald-600 underline-offset-2 hover:text-emerald-500 hover:underline"
>
Learn more
</Link>
</p>
<button
type="button"
onClick={handleAccept}
className="shrink-0 rounded-lg px-5 py-2.5 text-sm font-medium text-white outline-none transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
style={{ backgroundColor: "var(--accent-hex)" }}
>
Accept
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,16 @@
"use client"
import { useEffect } from "react"
/**
* Triggers the browser debugger when the component mounts.
* Only runs in development. Remove when done debugging.
*/
export function DebugTrigger() {
useEffect(() => {
if (process.env.NODE_ENV === "development") {
debugger
}
}, [])
return null
}

View file

@ -0,0 +1,127 @@
import { drupal } from "@/lib/drupal"
import type { DrupalAboutNode } from "@/lib/types"
import { AvatarImage } from "./avatar-image"
import { MailToLink } from "./mail-to-link"
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
const FALLBACK_TITLE = "Robert Nasarek"
function FallbackBody() {
return (
<>
<p className="mb-2 text-emerald-600 font-medium">
<span style={{ color: "#009966" }}>Data Engineer & Developer</span>
</p>
<p className="text-slate-600 leading-relaxed">
Im a freelance backend and data engineer specialising in data modelling, ETL pipelines, and data-centric application architecture. I design and implement scalable APIs and backend systems using Python and modern web frameworks like Next.js, Drupal, and Django to build robust data workflows for analytics and machine learning use cases.
</p>
<p className="text-slate-600 pt-4">
My focus is on semantic and structured data systems that turn heterogeneous sources into reliable, queryable, and reusable knowledge. I deliver production-ready solutions, including containerised deployments and reproducible data pipelines, with an emphasis on correctness, performance, and maintainability.
</p>
</>
)
}
async function getAboutPageContent(): Promise<{
title: string
body: string | null
email: string | null
}> {
if (!drupalBaseUrl) {
return {
title: FALLBACK_TITLE,
body: null,
email: null,
}
}
try {
const translatedPath = await drupal.translatePath("/about/robert-nasarek", {
withAuth: true,
next: { revalidate: 60 },
})
if (
!translatedPath?.jsonapi?.resourceName ||
!translatedPath?.entity?.uuid ||
translatedPath.jsonapi.resourceName !== "node--about"
) {
return { title: FALLBACK_TITLE, body: null, email: null }
}
const node = await drupal.getResource<DrupalAboutNode>(
"node--about",
translatedPath.entity.uuid,
{ withAuth: true, next: { revalidate: 60 } }
)
if (!node) {
return { title: FALLBACK_TITLE, body: null, email: null }
}
const bodyObj = node.body
const bodyText =
typeof bodyObj === "string"
? bodyObj
: bodyObj?.processed ?? bodyObj?.value ?? ""
return {
title: node.title ?? FALLBACK_TITLE,
body: bodyText || null,
email: node.field_email ?? null,
}
} catch (error) {
if ((error as Error).name !== "AbortError") {
console.warn("[HomeAbout] CMS unreachable:", (error as Error).message)
}
return { title: FALLBACK_TITLE, body: null, email: null }
}
}
export async function HomeAbout() {
const { title, body, email } = await getAboutPageContent()
return (
<section className="home-about animate-fade-in-on-load pb-10" aria-labelledby="about-heading">
<div className="home-about-header mb-8 text-center">
<h2
id="about-heading"
className="home-about-title mb-3 font-bold tracking-tight text-slate-900"
style={{ fontSize: "var(--fluid-section-title)" }}
>
About me
</h2>
<p
className="home-about-description mx-auto max-w-3xl text-slate-600"
style={{ fontSize: "var(--fluid-hero-desc)" }}
>
Data engineer and developer with a focus on linked open data, ontology engineering, and full-stack development.
</p>
</div>
<div className="home-about-content mx-auto max-w-4xl">
<div className="home-about-bio flex flex-col gap-6 rounded-xl border border-slate-200 bg-slate-50 p-6 sm:flex-row sm:items-start sm:gap-8 sm:p-8">
<AvatarImage alt={title} />
<div className="min-w-0 flex-1">
<h3 className="mb-2 text-xl font-semibold text-slate-900">
{title}
</h3>
<div
className="pemerald pemerald-slate text-slate-600 pemerald-a:text-emerald-600 pemerald-a:underline hover:pemerald-a:text-emerald-500 [&>p]:leading-relaxed [&>p:first-child]:mb-2 [&>p:first-child]:text-emerald-600 [&>p:first-child]:font-medium [&>p:not(:first-child)]:pt-4"
>
<p className="mb-2 text-emerald-600 font-medium">
<span style={{ color: "#009966" }}>Data Engineer & Developer</span>
</p>
<p className="text-slate-600 leading-relaxed">
Hi Im a freelance backend and data engineer specialising in data modelling, ETL pipelines, and data-centric application architecture. I design and implement scalable APIs and backend systems using Python and modern web frameworks like Next.js, Drupal, and Django to build robust data workflows for analytics and machine learning use cases.
</p>
<p className="text-slate-600 pt-4">
My focus is on semantic and structured data systems that turn heterogeneous sources into reliable, queryable, and reusable knowledge. I deliver production-ready solutions, including containerised deployments and reproducible data pipelines, with an emphasis on correctness, performance, and maintainability.
</p>
</div>
<p className="mt-4">
<MailToLink email={email} />
</p>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,223 @@
"use client"
import Image from "next/image"
import { Building2 } from "lucide-react"
import { useState, useRef, useEffect } from "react"
function ClientIcon({ icon }: { icon?: string }) {
const [hasError, setHasError] = useState(false)
if (!icon || hasError) {
return <Building2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
}
return (
<Image
src={icon}
alt=""
width={16}
height={16}
className="size-4 shrink-0 rounded-sm object-contain"
unoptimized
onError={() => setHasError(true)}
/>
)
}
const clients = [
{
name: "Max Planck Institute for Social Anthropology",
location: "Halle/Saale",
href: "https://www.eth.mpg.de/",
icon: "/assets/icons/eth-mpg.png",
},
{
name: "German National Academy of Sciences Leopoldina",
location: "Halle/Saale",
href: "https://www.leopoldina.org/",
icon: "/assets/icons/leopoldina.png",
},
{
name: "German National Museum",
location: "Nuremberg",
href: "https://www.gnm.de/",
icon: "/assets/icons/gnm.png",
},
{
name: "Central Institute for Art History",
location: "Munich",
href: "https://www.zikg.eu/",
icon: "/assets/icons/zikg.png",
},
{
name: "German Fairy Tale and Weser Legends Museum",
location: "Bad Oeynhausen",
href: "https://www.badoeynhausen.de/freizeit-kultur-sport/kultur/staedtische-museen/deutsches-maerchen-und-wesersagenmuseum",
icon: "/assets/icons/badoeynhausen.png",
},
{
name: "Roli-Bar",
location: "roli-bar.de",
href: "https://roli-bar.de/",
icon: "/assets/icons/roli-bar.png",
},
{
name: "Re-Cycle Halle",
location: "Halle/Saale",
href: "https://re-cycle-halle.de/",
icon: "/assets/icons/re-cycle-halle.png",
},
{
name: "bold + bündig",
location: "Leipzig",
href: "https://boldundbuendig.de/",
icon: "/assets/icons/boldundbuendig.png",
},
]
function ClientsBandContent() {
return (
<>
{clients.map((client) => {
const content = (
<>
<ClientIcon icon={client.icon} />
<span className="font-medium">{client.name}</span>
<span className="text-slate-500">({client.location})</span>
</>
)
const className =
"home-clients-band-item mx-4 flex shrink-0 items-center gap-2 rounded-xl border border-slate-200 bg-white px-6 py-4 text-base text-slate-700 shadow-sm"
return client.href ? (
<a
key={client.name}
href={client.href}
target="_blank"
rel="noopener noreferrer"
className={`${className} outline-none transition-colors hover:border-emerald-200 hover:text-emerald-600 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2`}
>
{content}
</a>
) : (
<span key={client.name} className={className}>
{content}
</span>
)
})}
</>
)
}
const SCROLL_SPEED = 40
export function HomeClients() {
const bandRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const posRef = useRef(0)
const isHoveredRef = useRef(false)
const isDraggingRef = useRef(false)
const dragStartXRef = useRef(0)
const dragStartPosRef = useRef(0)
const lastTimeRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const [isDragging, setIsDragging] = useState(false)
useEffect(() => {
const track = trackRef.current
if (!track) return
const step = (time: number) => {
const halfWidth = track.scrollWidth / 2
if (halfWidth > 0 && lastTimeRef.current !== null && !isHoveredRef.current && !isDraggingRef.current) {
const dt = time - lastTimeRef.current
posRef.current += SCROLL_SPEED * dt / 1000
if (posRef.current >= halfWidth) posRef.current -= halfWidth
}
lastTimeRef.current = time
track.style.transform = `translateX(${-posRef.current}px)`
rafRef.current = requestAnimationFrame(step)
}
rafRef.current = requestAnimationFrame(step)
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
}, [])
const handleMouseEnter = () => { isHoveredRef.current = true }
const handleMouseLeave = () => {
isHoveredRef.current = false
isDraggingRef.current = false
setIsDragging(false)
}
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
isDraggingRef.current = true
dragStartXRef.current = e.clientX
dragStartPosRef.current = posRef.current
setIsDragging(true)
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDraggingRef.current) return
const track = trackRef.current
if (!track) return
const halfWidth = track.scrollWidth / 2
const dx = dragStartXRef.current - e.clientX
posRef.current = ((dragStartPosRef.current + dx) % halfWidth + halfWidth) % halfWidth
}
const handleMouseUp = () => {
isDraggingRef.current = false
setIsDragging(false)
}
const handleTouchStart = (e: React.TouchEvent) => {
isDraggingRef.current = true
dragStartXRef.current = e.touches[0].clientX
dragStartPosRef.current = posRef.current
}
const handleTouchMove = (e: React.TouchEvent) => {
if (!isDraggingRef.current) return
const track = trackRef.current
if (!track) return
const halfWidth = track.scrollWidth / 2
const dx = dragStartXRef.current - e.touches[0].clientX
posRef.current = ((dragStartPosRef.current + dx) % halfWidth + halfWidth) % halfWidth
}
const handleTouchEnd = () => { isDraggingRef.current = false }
return (
<section className="home-clients py-10" aria-labelledby="clients-heading">
<div className="home-clients-header mb-6 text-center">
<h2
id="clients-heading"
className="home-clients-title mb-3 font-bold tracking-tight text-slate-900"
style={{ fontSize: "var(--fluid-section-title)" }}
>
Employers & Customers
</h2>
<p
className="home-clients-description mx-auto max-w-3xl text-slate-600"
style={{ fontSize: "var(--fluid-hero-desc)" }}
>
Research institutions and organisations I have worked with.
</p>
</div>
<div
ref={bandRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className="home-clients-band relative w-full overflow-hidden border-y border-slate-200 bg-slate-50 py-10 select-none"
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
<div ref={trackRef} className="home-clients-band-track flex w-max">
<ClientsBandContent />
<ClientsBandContent />
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,24 @@
import Link from "next/link"
import { ArrowRight } from "lucide-react"
export function HomeCta() {
return (
<section className="home-cta py-10">
<div className="home-cta-card animate-fade-in-up rounded-2xl border border-slate-200 bg-linear-to-br from-slate-50 to-emerald-50/30 p-8 text-center sm:p-10">
<h2 className="home-cta-title mb-3 font-bold tracking-tight text-slate-900" style={{ fontSize: "var(--fluid-section-title)" }}>
Hi There!
</h2>
<p className="home-cta-description mx-auto mb-6 max-w-xl text-slate-600">
Browse articles and data models for your next project.
</p>
<Link
href="/resources"
className="home-cta-link inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 font-medium text-white outline-none transition-all duration-200 ease-out hover:bg-emerald-500 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
>
View all resources
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</section>
)
}

View file

@ -0,0 +1,63 @@
import Link from "next/link"
import { FileText, Database, ArrowRight } from "lucide-react"
const features = [
{
title: "Articles",
description:
"Tutorials, guides, and insights on data engineering and software development.",
href: "/resources/articles",
icon: FileText,
color: "emerald",
},
{
title: "Datamodelling",
description:
"Structured data models and schemas from real-world projects.",
href: "/resources/datamodelling",
icon: Database,
color: "fuchsia",
},
]
export function HomeFeatures() {
return (
<section className="home-features py-10">
<div className="home-features-header mb-6 text-center">
<h2 className="home-features-title mb-3 text-3xl font-bold tracking-tight text-slate-900">
Resources
</h2>
<p className="home-features-description mx-auto max-w-3xl text-slate-600" style={{ fontSize: "var(--fluid-hero-desc)" }}>
Use one or all. Curated content for data engineers and developers.
</p>
</div>
<div className="home-features-grid grid gap-8 sm:grid-cols-2">
{features.map((feature, index) => (
<Link
key={feature.href}
href={feature.href}
className={`home-features-card group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-emerald-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 animate-fade-in-up ${index === 1 ? "animate-delay-100" : ""}`}
>
<div
className={`home-features-card-icon mb-4 inline-flex rounded-lg p-3 ${
feature.color === "emerald"
? "bg-emerald-100 text-emerald-600"
: "bg-fuchsia-100 text-fuchsia-600"
}`}
>
<feature.icon className="size-6" aria-hidden />
</div>
<h3 className="home-features-card-title mb-2 text-xl font-semibold text-slate-900">
{feature.title}
</h3>
<p className="home-features-card-description mb-4 text-slate-600">{feature.description}</p>
<span className="home-features-card-link inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
Learn more
<ArrowRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-1" aria-hidden />
</span>
</Link>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,11 @@
import { AnimatedHeroTitle } from "./animated-hero-title"
export function HomeHero() {
return (
<section className="home-hero relative pt-6">
<div className="home-hero-content mx-auto max-w-4xl text-center">
<AnimatedHeroTitle />
</div>
</section>
)
}

View file

@ -0,0 +1,196 @@
"use client"
import React, { useEffect, useState } from "react"
import {
Lightbulb,
PenTool,
Code2,
Rocket,
Wrench,
MessageSquare,
LifeBuoy,
} from "lucide-react"
const STATION_COLORS: Record<
string,
{ border: string; bg: string; text: string; label: string }
> = {
idea: { border: "border-amber-400", bg: "bg-amber-50", text: "text-amber-600", label: "text-amber-700" },
concept: { border: "border-emerald-400", bg: "bg-emerald-50", text: "text-emerald-600", label: "text-emerald-700" },
consulting: { border: "border-sky-400", bg: "bg-sky-50", text: "text-sky-600", label: "text-sky-700" },
development: { border: "border-fuchsia-400", bg: "bg-fuchsia-100", text: "text-fuchsia-600", label: "text-fuchsia-700" },
deployment: { border: "border-sky-400", bg: "bg-sky-50", text: "text-sky-600", label: "text-sky-700" },
maintenance: { border: "border-slate-400", bg: "bg-slate-100", text: "text-slate-600", label: "text-slate-700" },
support: { border: "border-rose-400", bg: "bg-rose-50", text: "text-rose-600", label: "text-rose-700" },
}
const STATIONS = [
{ id: "idea", icon: Lightbulb, label: "Idea", lineThreshold: 0 },
{ id: "concept", icon: PenTool, label: "Concept", lineThreshold: 15 },
{ id: "deployment", icon: Rocket, label: "Deployment", lineThreshold: 45 },
{ id: "maintenance", icon: Wrench, label: "Maintenance", lineThreshold: 80 },
] as const
const RIGHT_STATIONS = [
{ id: "consulting", icon: MessageSquare, label: "Consulting", lineThreshold: 0 },
{ id: "development", icon: Code2, label: "Development", lineThreshold: 25 },
{ id: "support", icon: LifeBuoy, label: "Support", lineThreshold: 60 },
] as const
export function HomeJourneyBackground({ children }: { children: React.ReactNode }) {
const childArray = React.Children.toArray(children)
const heroContent = childArray[0]
const mainContent = childArray.slice(1)
const [visibleStations, setVisibleStations] = useState<Set<string>>(new Set())
const [visibleRightStations, setVisibleRightStations] = useState<Set<string>>(
new Set()
)
useEffect(() => {
const handleScroll = () => {
const y = window.scrollY
const docHeight = document.documentElement.scrollHeight - window.innerHeight
const scrollProgress = docHeight > 0 ? Math.min(y / docHeight, 1) : 0
const ideaThreshold = 0.02
const linePercent =
scrollProgress >= ideaThreshold
? Math.min(
100,
((scrollProgress - ideaThreshold) / (1 - ideaThreshold)) * 100
)
: 0
const newVisible = new Set<string>()
if (scrollProgress >= ideaThreshold) {
newVisible.add("idea")
}
STATIONS.forEach((station) => {
if (station.id !== "idea" && linePercent >= station.lineThreshold) {
newVisible.add(station.id)
}
})
setVisibleStations(newVisible)
const newVisibleRight = new Set<string>()
if (scrollProgress >= ideaThreshold) {
newVisibleRight.add("consulting")
}
RIGHT_STATIONS.forEach((station) => {
if (
station.id !== "consulting" &&
linePercent >= station.lineThreshold
) {
newVisibleRight.add(station.id)
}
})
setVisibleRightStations(newVisibleRight)
}
handleScroll()
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
return (
<div className="journey-layout -mt-6 flex w-full flex-col px-4 lg:px-6">
<div className="journey-hero w-full">{heroContent}</div>
<div className="journey-main-row flex w-full items-stretch gap-4 lg:gap-6">
<aside
className="journey-sidebar relative sticky top-24 hidden min-h-full shrink-0 flex-[1] pb-8 pr-4 pt-0 lg:block"
aria-label="Journey timeline"
>
<div className="journey-stations relative flex h-full flex-col">
<div
className="journey-sidebar-spacer journey-sidebar-spacer-top min-h-0 flex-[1]"
aria-hidden
/>
<div className="journey-stations-list flex min-h-0 flex-[4] flex-col items-center justify-between">
{STATIONS.map((station) => {
const isVisible = visibleStations.has(station.id)
const colors = STATION_COLORS[station.id]
return (
<div
key={station.id}
className={`journey-station journey-station-${station.id} relative z-10 flex flex-col items-center gap-2`}
>
<div
className={`journey-station-icon flex size-20 shrink-0 items-center justify-center rounded-full border-2 shadow-sm transition-all duration-500 ${
isVisible
? `${colors.border} ${colors.bg} opacity-100 scale-100`
: "border-slate-200 bg-white opacity-0 scale-90"
}`}
>
<station.icon
className={`size-11 transition-all duration-500 ${
isVisible ? colors.text : "text-slate-400"
}`}
aria-hidden
/>
</div>
<h3
className={`journey-station-label text-center text-m font-medium transition-all duration-500 ${
isVisible ? `${colors.label} opacity-100` : "text-slate-500 opacity-0"
}`}
>
{station.label}
</h3>
</div>
)
})}
</div>
</div>
</aside>
<div className="journey-content main-contents-main min-w-0 flex-[8]">
{mainContent}
</div>
<aside
className="journey-sidebar journey-sidebar-right relative sticky top-24 hidden min-h-full shrink-0 flex-[1] pb-8 pl-4 pt-0 lg:block"
aria-label="Consulting and support journey"
>
<div className="journey-stations relative flex h-full flex-col">
<div
className="journey-sidebar-spacer journey-sidebar-spacer-top min-h-0 flex-[1]"
aria-hidden
/>
<div className="journey-stations-list flex min-h-0 flex-[4] flex-col items-center justify-around">
{RIGHT_STATIONS.map((station) => {
const isVisible = visibleRightStations.has(station.id)
const colors = STATION_COLORS[station.id]
return (
<div
key={station.id}
className={`journey-station journey-station-${station.id} relative z-10 flex flex-col items-center gap-2`}
>
<div
className={`journey-station-icon flex size-20 shrink-0 items-center justify-center rounded-full border-2 shadow-sm transition-all duration-500 ${
isVisible
? `${colors.border} ${colors.bg} opacity-100 scale-100`
: "border-slate-200 bg-white opacity-0 scale-90"
}`}
>
<station.icon
className={`size-11 transition-all duration-500 ${
isVisible ? colors.text : "text-slate-400"
}`}
aria-hidden
/>
</div>
<h3
className={`journey-station-label text-center text-m font-medium transition-all duration-500 ${
isVisible
? `${colors.label} opacity-100`
: "text-slate-500 opacity-0"
}`}
>
{station.label}
</h3>
</div>
)
})}
</div>
</div>
</aside>
</div>
</div>
)
}

View file

@ -0,0 +1,36 @@
"use client"
const testimonials = [
"Clean, fast, and well documented.",
"Best developer experience I've had in years.",
"Went from zero to production in minutes.",
"The documentation is a joy to read.",
"Scales effortlessly with my needs.",
"Exactly what I needed for my project.",
]
function MarqueeContent() {
return (
<>
{testimonials.map((testimonial, index) => (
<span
key={index}
className="home-marquee-item mx-4 flex shrink-0 items-center gap-2 rounded-full border border-slate-200 bg-white px-6 py-3 text-sm text-slate-600 shadow-sm"
>
{testimonial}
</span>
))}
</>
)
}
export function HomeMarquee() {
return (
<section className="home-marquee relative left-1/2 w-screen -translate-x-1/2 overflow-hidden border-y border-slate-200 bg-slate-50 py-8">
<div className="home-marquee-track flex w-max animate-marquee">
<MarqueeContent />
<MarqueeContent />
</div>
</section>
)
}

View file

@ -0,0 +1,88 @@
import Link from "next/link"
import Image from "next/image"
import { ArrowUpRight } from "lucide-react"
import { ScrollRevealCard } from "@/components/scroll-reveal-card"
const projects = [
{
title: "Böhler re:search",
description:
"Digital edition of the Munich art dealer Julius Böhler's object card system, photo folders and customer index (19031948). Research data on traded artworks, transactions and actors.",
href: "https://boehler.zikg.eu/",
icon: "/assets/icons/boehler-research.png",
},
{
title: "Objektsprache und Ästhetik",
description:
"Shell collections at Leopoldina, Goldfuß-Museum Bonn, and Central Institute for Natural Collections MLU. Historical object references and synonym networks for conchylia.",
href: "https://konchylien.leopoldina.org/sammlungen",
icon: "/assets/logos/lzfw_logo.png",
},
{
title: "SCS Manager",
description:
"Semantic Co-Working Space for academic university collections. Model, transform, analyse and publish data with JupyterLab, OpenRefine, WissKI and more.",
href: "https://manager.scs.sammlungen.io/",
icon: "/assets/icons/scs-manager.png",
},
{
title: "WissKI",
description:
"Semantic data management system for GLAM institutions. Virtual research environment extending Drupal with CIDOC CRM, Pathbuilder and linked open data.",
href: "https://wiss-ki.eu/",
icon: "/assets/icons/wisski.svg",
},
]
export function HomeProjects() {
return (
<section className="home-projects py-10" aria-labelledby="projects-heading">
<div className="home-projects-header mb-6 text-center">
<h2
id="projects-heading"
className="home-projects-title mb-3 font-bold tracking-tight text-slate-900"
style={{ fontSize: "var(--fluid-section-title)" }}
>
Projects
</h2>
<p
className="home-projects-description mx-auto max-w-3xl text-slate-600"
style={{ fontSize: "var(--fluid-hero-desc)" }}
>
Data- and information-focused websites and applications.
</p>
</div>
<div className="home-projects-grid grid gap-8 sm:grid-cols-2">
{projects.map((project) => (
<ScrollRevealCard key={project.href}>
<Link
href={project.href}
target="_blank"
rel="noopener noreferrer"
className="home-projects-card group block rounded-xl border border-slate-200 bg-white p-8 outline-none transition-all duration-200 ease-out hover:border-emerald-500/40 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2"
>
<h3 className="home-projects-card-title mb-2 flex items-center gap-3 text-xl font-semibold text-slate-900">
<Image
src={project.icon}
alt=""
width={24}
height={24}
className="size-6 shrink-0 object-contain"
unoptimized
/>
{project.title}
</h3>
<p className="home-projects-card-description mb-4 text-slate-600">
{project.description}
</p>
<span className="home-projects-card-link inline-flex items-center gap-2 text-sm font-medium text-emerald-600 transition-colors duration-200 ease-out group-hover:text-emerald-500">
Visit project
<ArrowUpRight className="size-4 transition-transform duration-200 ease-out group-hover:translate-x-0.5 group-hover:-translate-y-0.5" aria-hidden />
</span>
</Link>
</ScrollRevealCard>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,146 @@
import { Book, Brain, Calendar, Cpu, Code2, Database, Rocket, Unplug, Wrench } from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { drupal } from "@/lib/drupal"
import type { DrupalServiceNode } from "@/lib/types"
import { ScrollRevealCard } from "@/components/scroll-reveal-card"
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
const ICON_MAP: Record<string, LucideIcon> = {
coordination: Calendar,
data_processing: Cpu,
deployment: Rocket,
development: Code2,
documentation: Book,
interface_api: Unplug,
interface_and_api: Unplug,
maintainance: Wrench,
maintenance: Wrench,
modelling: Database,
ai: Brain,
}
function toIconKey(type: string): string {
return type.toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_")
}
function getIcon(serviceType: string | undefined): LucideIcon {
if (!serviceType) return Database
const key = toIconKey(serviceType)
return ICON_MAP[key] ?? Database
}
function stripHtml(html: string | undefined): string {
if (!html) return ""
return html.replace(/<[^>]*>/g, "").trim()
}
async function getServices(): Promise<
{ label: string; body: string; icon: LucideIcon }[]
> {
if (!drupalBaseUrl) return []
try {
let raw: { data?: DrupalServiceNode[] } | null = null
try {
raw = await drupal.getResourceCollection<{
data: DrupalServiceNode[]
}>("node--service", {
params: {
"filter[status]": "1",
sort: "created",
},
deserialize: false,
next: { revalidate: 60 },
})
} catch (firstError) {
const msg = (firstError as Error).message ?? ""
if (msg.includes("Unauthorized")) {
await new Promise((r) => setTimeout(r, 1000))
raw = await drupal.getResourceCollection<{
data: DrupalServiceNode[]
}>("node--service", {
params: {
"filter[status]": "1",
sort: "created",
},
deserialize: false,
next: { revalidate: 60 },
})
} else {
throw firstError
}
}
const nodes = raw?.data ?? []
if (!nodes.length) return []
return nodes.map((node) => {
const bodyObj = node.body
const bodyText =
typeof bodyObj === "string"
? stripHtml(bodyObj)
: stripHtml(bodyObj?.value ?? bodyObj?.processed)
return {
body: bodyText,
icon: getIcon(node.field__service__type),
label: node.title ?? "",
}
})
} catch (error) {
if ((error as Error).name !== "AbortError") {
console.warn(
"[HomeServices] CMS unreachable:",
(error as Error).message
)
}
return []
}
}
export async function HomeServices() {
const services = await getServices()
return (
<section className="home-services py-10" aria-labelledby="services-heading">
<div className="home-services-header mb-6 text-center">
<h2
id="services-heading"
className="home-services-title mb-3 font-bold tracking-tight text-slate-900"
style={{ fontSize: "var(--fluid-section-title)" }}
>
Services
</h2>
<p className="home-services-description mx-auto max-w-3xl text-slate-600" style={{ fontSize: "var(--fluid-hero-desc)" }}>
Data engineering, development, deployment, and ongoing support for
your projects.
</p>
</div>
<div className="home-services-grid mx-2 flex flex-col gap-5 sm:mx-4 lg:mx-6">
{services.map(({ label, body, icon: Icon }) => (
<ScrollRevealCard key={label}>
<div
className="home-services-card mx-auto flex max-w-[1000px] min-h-0 w-full flex-col items-center justify-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-5 py-5 transition-colors hover:border-emerald-200 hover:bg-emerald-50/50 sm:min-h-[7rem] sm:flex-row sm:gap-10 sm:px-10 sm:py-8"
>
<Icon
className="home-services-card-icon size-8 shrink-0 text-emerald-600 sm:size-10 lg:size-12"
aria-hidden
/>
<div className="flex min-w-0 flex-1 flex-col items-center justify-center gap-1.5 text-center sm:gap-2">
<span className="home-services-card-label text-lg font-semibold text-slate-800 sm:text-xl lg:text-2xl">
{label}
</span>
{body && (
<span className="home-services-card-body text-sm leading-snug text-slate-600 sm:text-base lg:text-lg">
{body}
</span>
)}
</div>
</div>
</ScrollRevealCard>
))}
</div>
</section>
)
}

View file

@ -0,0 +1,57 @@
"use client"
import { ObfuscatedEmail } from "@/components/obfuscated-email"
import { ObfuscatedAddress } from "@/components/obfuscated-address"
interface ImprintBodyProps {
html: string
}
/**
* Renders imprint HTML with {email} and {address} placeholders replaced by
* ObfuscatedEmail and ObfuscatedAddress components.
*/
export function ImprintBody({ html }: ImprintBodyProps) {
const placeholderRegex = /\{(email|address)\}/g
const parts: (string | React.ReactNode)[] = []
let lastIndex = 0
let match
let key = 0
while ((match = placeholderRegex.exec(html)) !== null) {
const before = html.slice(lastIndex, match.index)
if (before) {
parts.push(
<span
key={`html-${key++}`}
style={{ display: "contents" }}
dangerouslySetInnerHTML={{ __html: before }}
/>
)
}
if (match[1] === "email") {
parts.push(
<ObfuscatedEmail
key={`email-${key++}`}
className="text-emerald-600 outline-none transition-colors hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
/>
)
} else {
parts.push(<ObfuscatedAddress key={`addr-${key++}`} />)
}
lastIndex = match.index + match[0].length
}
const after = html.slice(lastIndex)
if (after) {
parts.push(
<span
key={`html-${key++}`}
style={{ display: "contents" }}
dangerouslySetInnerHTML={{ __html: after }}
/>
)
}
return <>{parts}</>
}

View file

@ -0,0 +1,47 @@
"use client"
import { useEffect, useState } from "react"
import { Mail } from "lucide-react"
interface MailToLinkProps {
/** When provided (e.g. from Drupal field_email), use this email. Otherwise use fallback. */
email?: string | null
}
/**
* Mailto link with icon; email is set on client to reduce harvestability when not passed as prop.
*/
export function MailToLink({ email }: MailToLinkProps) {
const [href, setHref] = useState<string | null>(email ? `mailto:${email}` : null)
useEffect(() => {
if (email) {
setHref(`mailto:${email}`)
return
}
const localPart = "robert"
const domain = "nasarek"
const tld = "dev"
setHref(`mailto:${localPart}@${domain}.${tld}`)
}, [email])
if (!href) {
return (
<span className="inline-flex items-center gap-2 text-slate-500">
<Mail className="size-4 shrink-0" aria-hidden />
<span>Write me</span>
</span>
)
}
return (
<a
href={href}
className="inline-flex items-center gap-2 text-emerald-600 outline-none transition-colors duration-200 ease-out hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2"
aria-label="Write me an email"
>
<Mail className="size-4 shrink-0" aria-hidden />
<span>Write me</span>
</a>
)
}

View file

@ -0,0 +1,156 @@
"use client"
import type { DrupalMenuItem } from "next-drupal"
import { Home, FolderOpen, ChevronDown } from "lucide-react"
import { useState, useRef, useEffect } from "react"
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
function getHref(url: string): string {
if (drupalBaseUrl && url.startsWith(drupalBaseUrl)) {
return url.slice(drupalBaseUrl.length) || "/"
}
return url
}
function NavLink({
item,
isDropdown = false,
onNavigate,
}: {
item: DrupalMenuItem
isDropdown?: boolean
onNavigate?: () => void
}) {
const children = item.items?.filter((child) => child.enabled !== false) ?? []
const hasChildren = children.length > 0
const linkClass = isDropdown
? "block rounded-sm px-4 py-2 text-emerald-500 outline-none transition-colors duration-200 ease-out hover:underline focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-inset"
: "flex items-center gap-1.5 rounded-sm text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-400 hover:underline focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800"
if (hasChildren) {
return (
<div className="group relative">
<a
href={getHref(item.url)}
className={`${linkClass} flex items-center gap-1.5`}
onClick={onNavigate}
>
<FolderOpen className="size-4" aria-hidden />
{item.title}
<ChevronDown className="size-4 transition-transform duration-200 ease-out group-hover:rotate-180" aria-hidden />
</a>
<div className="absolute left-0 top-full pt-2 opacity-0 pointer-events-none transition-all duration-200 ease-out group-hover:opacity-100 group-hover:pointer-events-auto translate-y-[-4px] group-hover:translate-y-0">
<div className="rounded-md border border-slate-200 bg-white py-2 shadow-md">
{children.map((child) => (
<a
key={child.id}
href={getHref(child.url)}
className="block rounded-sm px-4 py-2 text-emerald-600 outline-none transition-colors duration-200 ease-out hover:bg-slate-50 hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-inset"
onClick={onNavigate}
>
{child.title}
</a>
))}
</div>
</div>
</div>
)
}
const href = getHref(item.url)
const isHome = href === "/"
return (
<a href={href} className={linkClass} onClick={onNavigate}>
{isHome && <Home className="size-4 shrink-0" aria-hidden />}
{item.title}
</a>
)
}
interface MainNavClientProps {
menuItems: DrupalMenuItem[]
}
export function MainNavClient({ menuItems }: MainNavClientProps) {
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const enabledItems = menuItems.filter((item) => item.enabled !== false)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener("click", handleClickOutside)
}
return () => document.removeEventListener("click", handleClickOutside)
}, [isOpen])
const closeMenu = () => setIsOpen(false)
return (
<nav className="flex items-center gap-8 text-sm font-medium" aria-label="Main">
{/* Desktop: full nav. */}
<div className="hidden lg:flex lg:items-center lg:gap-8">
{enabledItems.map((item) => (
<NavLink key={item.id} item={item} />
))}
</div>
{/* Mobile: MORE trigger and dropdown. */}
<div className="relative lg:hidden" ref={menuRef}>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="flex items-center rounded-sm px-3 py-2 text-lg font-bold uppercase tracking-wide text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-400 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800"
aria-expanded={isOpen}
aria-haspopup="true"
aria-label="Open menu"
>
MORE
</button>
{isOpen && (
<div className="absolute right-0 top-full z-50 mt-2 min-w-[12rem] rounded-md border border-slate-200 bg-white py-2 shadow-lg">
{enabledItems.map((item) => {
const children = item.items?.filter((c) => c.enabled !== false) ?? []
return (
<div key={item.id} className="border-b border-slate-100 last:border-b-0">
<a
href={getHref(item.url)}
className="flex items-center gap-1.5 rounded-sm px-4 py-2 text-emerald-500 hover:bg-slate-50 hover:text-emerald-400"
onClick={closeMenu}
>
{children.length > 0 ? (
<FolderOpen className="size-4" aria-hidden />
) : getHref(item.url) === "/" ? (
<Home className="size-4" aria-hidden />
) : null}
{item.title}
</a>
{children.length > 0 && (
<div className="pl-6 pb-1">
{children.map((child) => (
<a
key={child.id}
href={getHref(child.url)}
className="block rounded-sm px-2 py-1.5 text-sm text-emerald-500 hover:bg-slate-50 hover:text-emerald-400"
onClick={closeMenu}
>
{child.title}
</a>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</nav>
)
}

View file

@ -0,0 +1,76 @@
import type { DrupalMenuItem } from "next-drupal"
import { MainNavClient } from "./main-nav-client"
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
interface RawMenuItem {
id: string
parent: string
title: string
url: string
enabled?: boolean
weight?: string | number
}
function buildMenuTree(
items: RawMenuItem[],
parentId: string
): DrupalMenuItem[] {
return items
.filter((item) => (item.parent || "") === parentId && item.enabled !== false)
.sort((a, b) => Number(a.weight ?? 0) - Number(b.weight ?? 0))
.map((item) => {
const children = buildMenuTree(items, item.id)
return {
...item,
items: children.length ? children : undefined,
} as DrupalMenuItem
})
}
const FALLBACK_MENU: DrupalMenuItem[] = [
{
id: "home",
title: "Home",
url: "/",
enabled: true,
items: undefined,
} as DrupalMenuItem,
]
async function getMainMenu(): Promise<DrupalMenuItem[]> {
if (!drupalBaseUrl) return FALLBACK_MENU
try {
const url = `${drupalBaseUrl.replace(/\/$/, "")}/jsonapi/menu_items/main`
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const res = await fetch(url, {
headers: { Accept: "application/vnd.api+json" },
next: { revalidate: 60 },
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!res.ok) {
if (res.status !== 404) {
console.warn(`[MainNav] Menu fetch returned ${res.status}, using fallback nav.`)
}
return FALLBACK_MENU
}
const json = await res.json()
const items: RawMenuItem[] = json.data ?? []
return buildMenuTree(items, "")
} catch (error) {
if ((error as Error).name !== "AbortError") {
console.warn("[MainNav] CMS unreachable, using fallback nav:", (error as Error).message)
}
return FALLBACK_MENU
}
}
export async function MainNav() {
const menuItems = await getMainMenu()
return <MainNavClient menuItems={menuItems} />
}

View file

@ -0,0 +1,34 @@
import type { DrupalNode } from "@/lib/types"
interface NodeArticleTeaserProps {
node: DrupalNode
}
export function NodeArticleTeaser({ node }: NodeArticleTeaserProps) {
const href = node.path?.alias || `/node/${node.id}`
return (
<article className="group rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition hover:shadow-md">
<h3 className="mb-2 text-xl font-semibold">
<a href={href} className="text-gray-900 group-hover:text-emerald-500">
{node.title}
</a>
</h3>
<div className="mb-3 flex items-center gap-3 text-sm text-gray-500">
{node.uid?.display_name && (
<span>By {node.uid.display_name}</span>
)}
<time dateTime={node.created}>
{new Date(node.created).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</div>
{node.body?.summary && (
<p className="line-clamp-3 text-gray-600">{node.body.summary}</p>
)}
</article>
)
}

View file

@ -0,0 +1,35 @@
import type { DrupalNode } from "@/lib/types"
interface NodeArticleProps {
node: DrupalNode
}
export function NodeArticle({ node }: NodeArticleProps) {
return (
<article className="mx-auto max-w-4xl">
<h1 className="mb-4 text-4xl font-bold tracking-tight text-emerald-600">
{node.title}
</h1>
<div className="mb-8 flex items-center gap-3 text-sm text-slate-600">
{node.uid?.display_name && (
<span>By {node.uid.display_name}</span>
)}
<time dateTime={node.created}>
{new Date(node.created).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</div>
{(node.body?.processed ?? node.body?.value) && (
<div
className="pemerald pemerald-slate max-w-none pemerald-a:text-emerald-600 pemerald-a:underline hover:pemerald-a:text-emerald-500"
dangerouslySetInnerHTML={{
__html: node.body.processed ?? node.body?.value ?? "",
}}
/>
)}
</article>
)
}

View file

@ -0,0 +1,21 @@
import type { DrupalNode } from "@/lib/types"
interface NodePageProps {
node: DrupalNode
}
export function NodePage({ node }: NodePageProps) {
return (
<article className="mx-auto max-w-4xl">
<h1 className="mb-4 text-4xl font-bold tracking-tight text-emerald-600">
{node.title}
</h1>
{node.body?.processed && (
<div
className="pemerald pemerald-slate max-w-none pemerald-a:text-emerald-600 pemerald-a:underline hover:pemerald-a:text-emerald-500"
dangerouslySetInnerHTML={{ __html: node.body.processed }}
/>
)}
</article>
)
}

View file

@ -0,0 +1,49 @@
"use client"
import { useEffect, useState } from "react"
/**
* Renders a mailto link only after client mount so the email is not in the
* server-rendered HTML, reducing harvestability by bots that scan static HTML.
* Parts are hardcoded so they live in the JS bundle, not in page HTML.
*/
type AddressData = {
fullname: string
street: string
city: string
country: string
}
export function ObfuscatedAddress({ className }: { className?: string }) {
const [address, setAddress] = useState<AddressData | null>(null)
useEffect(() => {
setAddress({
fullname: "Robert Nasarek",
street: "Kleine Ulrichstraße 1",
city: "Halle (Saale)",
country: "Germany",
})
}, [])
if (!address) {
return (
<span className={className}>
<noscript>Robert Nasarek, Kleine Ulrichstraße 1, Halle (Saale), Germany</noscript>
<span aria-hidden></span>
</span>
)
}
return (
<p className={className}>
{address.fullname}
<br />
{address.street}
<br />
{address.city}
<br />
{address.country}
</p>
)
}

View file

@ -0,0 +1,34 @@
"use client"
import { useEffect, useState } from "react"
/**
* Renders a mailto link only after client mount so the email is not in the
* server-rendered HTML, reducing harvestability by bots that scan static HTML.
* Parts are hardcoded so they live in the JS bundle, not in page HTML.
*/
export function ObfuscatedEmail({ className }: { className?: string }) {
const [email, setEmail] = useState<string | null>(null)
useEffect(() => {
const localPart = "robert"
const domain = "nasarek"
const tld = "dev"
setEmail(`${localPart}@${domain}.${tld}`)
}, [])
if (!email) {
return (
<span className={className}>
<noscript>robert [at] nasarek [dot] dev</noscript>
<span aria-hidden></span>
</span>
)
}
return (
<a href={`mailto:${email}`} className={className}>
{email}
</a>
)
}

View file

@ -0,0 +1,38 @@
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
interface ScrollRevealCardProps {
children: ReactNode
}
export function ScrollRevealCard({ children }: ScrollRevealCardProps) {
const ref = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setIsVisible(true)
},
{ threshold: 0.35, rootMargin: "0px 0px -120px 0px" }
)
observer.observe(el)
return () => observer.disconnect()
}, [])
return (
<div
ref={ref}
className={`transition-all duration-700 ease-out ${
isVisible ? "translate-y-0 opacity-100" : "translate-y-12 opacity-20"
}`}
>
{children}
</div>
)
}

View file

@ -0,0 +1,56 @@
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
interface ScrollRevealSectionProps {
children: ReactNode
/** When true, section starts visible (no opacity-20 flash). Use for above-the-fold hero. */
initialVisible?: boolean
/** Delay in ms before the reveal animation starts after the section enters view. */
revealDelayMs?: number
}
export function ScrollRevealSection({
children,
initialVisible = false,
revealDelayMs = 0,
}: ScrollRevealSectionProps) {
const ref = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(initialVisible)
useEffect(() => {
const el = ref.current
if (!el) return
let timeoutId: ReturnType<typeof setTimeout> | null = null
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return
if (revealDelayMs <= 0) {
setIsVisible(true)
return
}
timeoutId = setTimeout(() => setIsVisible(true), revealDelayMs)
},
{ threshold: 0.1, rootMargin: "0px 0px -80px 0px" }
)
observer.observe(el)
return () => {
if (timeoutId) clearTimeout(timeoutId)
observer.disconnect()
}
}, [revealDelayMs])
return (
<div
ref={ref}
className={`transition-all duration-700 ease-out ${
isVisible ? "translate-y-0 opacity-100" : "translate-y-12 opacity-20"
}`}
>
{children}
</div>
)
}

View file

View file

@ -0,0 +1,20 @@
import { NextDrupal } from "next-drupal"
const baseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL!
const auth =
process.env.DRUPAL_CLIENT_ID && process.env.DRUPAL_CLIENT_SECRET
? {
clientId: process.env.DRUPAL_CLIENT_ID,
clientSecret: process.env.DRUPAL_CLIENT_SECRET,
...(process.env.DRUPAL_OAUTH_SCOPE && {
scope: process.env.DRUPAL_OAUTH_SCOPE,
}),
}
: undefined
export const drupal = new NextDrupal(baseUrl, {
auth,
withAuth: !!auth,
debug: process.env.NODE_ENV === "development",
})

View file

@ -0,0 +1,71 @@
import type { JsonApiResource } from "next-drupal"
// Drupal JSON:API resource types.
export interface DrupalNode extends JsonApiResource {
title: string
status: boolean
created: string
changed: string
path: {
alias: string
pid: number
langcode: string
}
body?: {
value: string
format: string
processed: string
summary: string
}
field_image?: DrupalMedia
uid?: {
id: string
display_name: string
}
metatag?: DrupalMetatag[]
}
export interface DrupalMedia extends JsonApiResource {
name: string
field_media_image?: DrupalFile
}
export interface DrupalFile extends JsonApiResource {
uri: {
value: string
url: string
}
resourceIdObjMeta?: {
alt: string
title: string
width: number
height: number
}
}
export interface DrupalMetatag {
tag: string
attributes: Record<string, string>
}
export interface DrupalMenuLinkContent {
id: string
title: string
url: string
parent: string
weight: number
expanded: boolean
enabled: boolean
items?: DrupalMenuLinkContent[]
}
export interface DrupalServiceNode extends DrupalNode {
/** Service type from Drupal (modelling, development, deployment, etc.). JSON:API exposes as field__service__type. */
field__service__type?: string
}
export interface DrupalAboutNode extends DrupalNode {
/** JSON:API resource type: node--about. */
field_email?: string
}

View file

@ -0,0 +1,20 @@
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
images: {
qualities: [75, 95],
remotePatterns: [
{
protocol: "https",
hostname: process.env.NEXT_IMAGE_DOMAIN || "cms.nasarek.dev",
},
],
},
// Enable standalone output for Docker.
output: "standalone",
// DevIndicators
devIndicators: false,
}
export default nextConfig

1969
drupal/nextjs/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
{
"name": "nasarek-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev:debug": "NODE_OPTIONS='--inspect' next dev",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
"docker:build": "docker build -t rnsrk/nextjs-frontend --build-arg NEXT_PUBLIC_DRUPAL_BASE_URL=https://cms.nasarek.dev .",
"docker:up": "cd .. && docker compose up -d nextjs --build"
},
"dependencies": {
"lucide-react": "^0.574.0",
"next": "^15.1",
"next-drupal": "^2.0.0",
"react": "^19.0",
"react-dom": "^19.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0",
"@types/node": "^22.0",
"@types/react": "^19.0",
"@types/react-dom": "^19.0",
"tailwindcss": "^4.0",
"typescript": "^5.7"
}
}

View file

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="97.97727mm" height="128.01656mm" viewBox="0 0 277.73084 362.8816"><title>Drupal</title><g id="Livello_2" data-name="Livello 2"><g id="Livello_1-2" data-name="Livello 1"><path d="M196.00843,77.29177C170.47408,51.76951,146.11187,27.43962,138.86135,0c-7.25088,27.43962-31.617,51.76951-57.14709,77.29177C43.41893,115.56293,0,158.93748,0,223.99184,0,300.57855,62.291,362.8816,138.86135,362.8816c76.58243,0,138.86524-62.29879,138.86949-138.88976,0-65.05011-43.41537-108.42891-81.72241-146.70007M59.02214,256.34977c-8.51464-.28912-39.93878-54.453,18.35806-112.124L115.95754,186.365s2.36878,2.22706-.25751,4.92082c-9.2055,9.44134-48.44171,48.78732-53.31849,62.39221-1.0066,2.80815-2.47674,2.70194-3.3594,2.67175m79.84347,71.38635a47.759,47.759,0,0,1-47.75939-47.75938c0-12.09214,4.80716-22.86793,11.90389-31.54632,8.61161-10.53036,35.8491-40.148,35.8491-40.148s26.8205,30.05237,35.78481,40.04746a46.706,46.706,0,0,1,11.981,31.64684,47.75949,47.75949,0,0,1-47.75937,47.75938m91.41133-77.44876c-1.02935,2.25121-3.36439,6.00948-6.516,6.12421-5.6177.2046-6.218-2.67388-10.37017-8.819-9.116-13.49017-88.67067-96.63406-103.5507-112.71395-13.08844-14.143-1.84309-24.11392,3.37325-29.33914,6.54441-6.55613,25.6473-25.64732,25.6473-25.64732s56.96026,54.04379,80.68775,90.97055,15.55027,68.88013,10.72856,79.42469" style="fill:#475569"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -0,0 +1,10 @@
<svg width="98" height="96" viewBox="0 0 98 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_730_27126)">
<path d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 6.69539e-07 48.9043 4.309e-07C21.8203 1.92261e-07 -1.9479e-07 22.1074 -4.3343e-07 49.1914C-6.20631e-07 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z" fill="#475569"/>
</g>
<defs>
<clipPath id="clip0_730_27126">
<rect width="98" height="96" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,3 @@
<svg width="74" height="79" viewBox="0 0 74 79" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M73.7014 17.4323C72.5616 9.05152 65.1774 2.4469 56.424 1.1671C54.9472 0.950843 49.3518 0.163818 36.3901 0.163818H36.2933C23.3281 0.163818 20.5465 0.950843 19.0697 1.1671C10.56 2.41145 2.78877 8.34604 0.903306 16.826C-0.00357854 21.0022 -0.100361 25.6322 0.068112 29.8793C0.308275 35.9699 0.354874 42.0498 0.91406 48.1156C1.30064 52.1448 1.97502 56.1419 2.93215 60.0769C4.72441 67.3445 11.9795 73.3925 19.0876 75.86C26.6979 78.4332 34.8821 78.8603 42.724 77.0937C43.5866 76.8952 44.4398 76.6647 45.2833 76.4024C47.1867 75.8033 49.4199 75.1332 51.0616 73.9562C51.0841 73.9397 51.1026 73.9184 51.1156 73.8938C51.1286 73.8693 51.1359 73.8421 51.1368 73.8144V67.9366C51.1364 67.9107 51.1302 67.8852 51.1186 67.862C51.1069 67.8388 51.0902 67.8184 51.0695 67.8025C51.0489 67.7865 51.0249 67.7753 50.9994 67.7696C50.9738 67.764 50.9473 67.7641 50.9218 67.7699C45.8976 68.9569 40.7491 69.5519 35.5836 69.5425C26.694 69.5425 24.3031 65.3699 23.6184 63.6327C23.0681 62.1314 22.7186 60.5654 22.5789 58.9744C22.5775 58.9477 22.5825 58.921 22.5934 58.8965C22.6043 58.8721 22.621 58.8505 22.6419 58.8336C22.6629 58.8167 22.6876 58.8049 22.714 58.7992C22.7404 58.7934 22.7678 58.794 22.794 58.8007C27.7345 59.9796 32.799 60.5746 37.8813 60.5733C39.1036 60.5733 40.3223 60.5733 41.5447 60.5414C46.6562 60.3996 52.0437 60.1408 57.0728 59.1694C57.1983 59.1446 57.3237 59.1233 57.4313 59.0914C65.3638 57.5847 72.9128 52.8555 73.6799 40.8799C73.7086 40.4084 73.7803 35.9415 73.7803 35.4523C73.7839 33.7896 74.3216 23.6576 73.7014 17.4323ZM61.4925 47.3144H53.1514V27.107C53.1514 22.8528 51.3591 20.6832 47.7136 20.6832C43.7061 20.6832 41.6988 23.2499 41.6988 28.3194V39.3803H33.4078V28.3194C33.4078 23.2499 31.3969 20.6832 27.3894 20.6832C23.7654 20.6832 21.9552 22.8528 21.9516 27.107V47.3144H13.6176V26.4937C13.6176 22.2395 14.7157 18.8598 16.9118 16.3545C19.1772 13.8552 22.1488 12.5719 25.8373 12.5719C30.1064 12.5719 33.3325 14.1955 35.4832 17.4394L37.5587 20.8853L39.6377 17.4394C41.7884 14.1955 45.0145 12.5719 49.2765 12.5719C52.9614 12.5719 55.9329 13.8552 58.2055 16.3545C60.4017 18.8574 61.4997 22.2371 61.4997 26.4937L61.4925 47.3144Z" fill="#475569"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,4 @@
<svg viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M149.508 157.52L69.142 54H54V125.97H66.1136V69.3836L139.999 164.845C143.333 162.132 146.509 159.256 149.508 156.208V157.52Z" fill="#475569"/>
<path d="M115 54H127V126H115V54Z" fill="#475569"/>
</svg>

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 11.9976C3.5 7.30452 7.30452 3.5 11.9976 3.5C16.6908 3.5 20.4953 7.30452 20.4953 11.9976C20.4953 16.6908 16.6908 20.4953 11.9976 20.4953C7.30452 20.4953 3.5 16.6908 3.5 11.9976ZM11.9976 1.5C6.19995 1.5 1.5 6.19995 1.5 11.9976C1.5 17.7953 6.19995 22.4953 11.9976 22.4953C17.7953 22.4953 22.4953 17.7953 22.4953 11.9976C22.4953 6.19995 17.7953 1.5 11.9976 1.5ZM6.58886 7.81169C6.48624 7.26902 6.84298 6.74592 7.38565 6.64331C7.98535 6.54239 8.59286 6.49319 9.20055 6.47725C10.2721 6.44914 11.7394 6.52235 13.294 6.93892C14.8487 7.35549 16.1561 8.02575 17.07 8.58585C17.5854 8.90177 18.0975 9.24236 18.5579 9.63641C18.9714 9.99199 19.0204 10.6337 18.6645 11.0474C18.3047 11.4656 17.6743 11.5133 17.2556 11.1543C16.8767 10.829 16.4494 10.5513 16.0249 10.2911C15.2209 9.79833 14.0926 9.22344 12.7764 8.87077C11.4602 8.5181 10.1957 8.45183 9.25299 8.47656C8.75397 8.48965 8.24933 8.52191 7.7568 8.60857C7.21429 8.7109 6.69144 8.35421 6.58886 7.81169ZM7.57136 12.3409C7.05016 12.5151 6.48542 12.2358 6.30795 11.715C6.12983 11.1922 6.40923 10.624 6.93201 10.4459C7.43418 10.2752 7.97927 10.2036 8.50474 10.1603C9.44887 10.0825 10.7702 10.1141 12.3312 10.5324C13.8922 10.9507 15.0523 11.5839 15.831 12.1234C16.2699 12.4274 16.6857 12.7662 17.0502 13.1571C17.4139 13.5727 17.3718 14.2045 16.9562 14.5681C16.5403 14.932 15.9139 14.8769 15.5464 14.4756C15.5138 14.4403 15.2316 14.1411 14.6921 13.7674C14.0759 13.3406 13.1234 12.8152 11.8135 12.4642C10.5037 12.1133 9.41608 12.092 8.669 12.1535C8.29606 12.1843 7.93296 12.2481 7.57136 12.3409ZM6.47141 14.18C5.99179 14.4538 5.82495 15.0646 6.09878 15.5442C6.36807 16.0159 6.96329 16.1851 7.43912 15.9301C7.63939 15.8384 7.87055 15.8011 8.08687 15.7738C8.62406 15.7061 9.52907 15.7036 10.8507 16.0577C12.1723 16.4118 12.9548 16.8665 13.3862 17.1937C13.7115 17.4406 13.8457 17.6204 13.8677 17.6511C14.178 18.0878 14.738 18.272 15.2227 17.989C15.6996 17.7105 15.8605 17.0981 15.5821 16.6212C15.3193 16.2207 14.9758 15.8893 14.5949 15.6004C13.9395 15.1031 12.9176 14.541 11.3683 14.1259C9.81907 13.7107 8.65294 13.6866 7.8367 13.7895C7.36239 13.8493 6.89923 13.9646 6.47141 14.18Z" fill="#475569"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -0,0 +1,20 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="397.000000pt" height="278.000000pt" viewBox="0 0 397.000000 278.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(0.000000,278.000000) scale(0.100000,-0.100000)"
fill="#475569" stroke="none">
<path d="M1297 2770 c-461 -16 -763 -44 -882 -81 -177 -54 -308 -207 -350
-407 -86 -416 -86 -1369 1 -1791 43 -208 196 -370 395 -415 85 -20 305 -42
574 -58 298 -17 1600 -17 1900 0 557 33 683 62 814 192 99 97 138 195 171 420
32 225 44 429 44 760 0 414 -31 786 -80 950 -60 205 -219 338 -444 374 -115
19 -282 33 -535 46 -257 14 -1313 20 -1608 10z m828 -1099 c259 -149 471 -275
473 -279 2 -6 -585 -352 -950 -560 l-58 -33 0 591 0 592 33 -20 c17 -11 244
-142 502 -291z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 984 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 51.046 51.046" fill="#475569">
<path d="m 28.324,20.044 c -0.043,-0.106 -0.084,-0.214 -0.131,-0.32 -0.707,-1.602 -1.656,-2.997 -2.848,-4.19 -1.188,-1.187 -2.582,-2.125 -4.184,-2.805 -1.605,-0.678 -3.309,-1.02 -5.104,-1.02 -1.85,0 -3.564,0.342 -5.137,1.02 -1.467,0.628 -2.764,1.488 -3.91,2.552 V 14.84 c 0,-1.557 -1.262,-2.822 -2.82,-2.822 h -19.775 c -1.557,0 -2.82,1.265 -2.82,2.822 0,1.559 1.264,2.82 2.82,2.82 h 15.541 l -18.23,24.546 c -0.362,0.487 -0.557,1.077 -0.557,1.682 v 1.841 c 0,1.558 1.264,2.822 2.822,2.822 H 5.038 c 1.488,0 2.705,-1.153 2.812,-2.614 0.932,0.743 1.967,1.364 3.109,1.848 1.605,0.684 3.299,1.021 5.102,1.021 2.723,0 5.15,-0.726 7.287,-2.187 1.727,-1.176 3.092,-2.639 4.084,-4.389 0.832799,-1.472094 1.418284,-2.633352 1.221889,-3.729182 -0.173003,-0.965318 -0.694914,-1.946419 -2.326865,-2.378358 -0.58,0 -1.376024,0.17454 -1.833024,0.49254 -0.463,0.316 -0.793,0.744 -0.982,1.275 l -0.453,0.93 c -0.631,1.365 -1.566,2.443 -2.809,3.244 -1.238,0.803 -2.633,1.201 -4.188,1.201 -1.023,0 -2.004,-0.191 -2.955,-0.579 -0.941,-0.39 -1.758,-0.935 -2.439,-1.64 C 9.986,40.343 9.441,39.526 9.027,38.603 8.617,37.679 8.41,36.71 8.41,35.687 v -2.476 h 17.715 c 0,0 1.517774,-0.15466 2.183375,-0.770672 0.958496,-0.887085 0.864622,-2.15038 0.864622,-2.15038 0,0 -0.04354,-5.066834 -0.338376,-7.578154 C 28.729048,21.812563 28.324,20.044 28.324,20.044 Z M -11.767,42.91 2.991,23.036 C 2.913,23.623 2.87,24.22 2.87,24.827 v 10.86 c 0,1.799 0.35,3.498 1.059,5.104 0.328,0.752 0.719,1.458 1.156,2.119 -0.016,0 -0.031,-10e-4 -0.047,-10e-4 H -11.767 Z M 23.71,27.667 H 8.409 v -2.841 c 0,-1.015 0.189,-1.99 0.58,-2.912 0.391,-0.922 0.936,-1.74 1.645,-2.444 0.697,-0.703 1.516,-1.249 2.438,-1.641 0.922,-0.388 1.92,-0.581 2.99,-0.581 1.02,0 2.002,0.193 2.949,0.581 0.949,0.393 1.764,0.938 2.441,1.641 0.682,0.704 1.225,1.521 1.641,2.444 0.414,0.922 0.617,1.896 0.617,2.912 z" transform="translate(20.35 -4.735)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -3,6 +3,6 @@ FROM nginx:latest
COPY ./nginx.conf.template /etc/nginx/nginx.conf.template
ARG DOMAIN
RUN sed 's|${DOMAIN}|'"$DOMAIN"'|g' /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
RUN envsubst '${DOMAIN}' < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf
ENTRYPOINT ["nginx", "-g", "daemon off;"]

View file

@ -18,11 +18,21 @@ http {
keepalive_timeout 65;
gzip on;
# Increase client body size for file uploads.
client_max_body_size 64M;
server {
listen 80;
server_name ${DOMAIN};
server_name cms.${DOMAIN};
root /var/www/html;
# JSON:API endpoint caching headers.
location /jsonapi {
try_files $uri /index.php$is_args$args;
add_header Cache-Control "public, max-age=60";
add_header X-Content-Type-Options nosniff;
}
location / {
try_files $uri /index.php$is_args$args;
}
@ -33,9 +43,10 @@ http {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_read_timeout 120;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
try_files $uri @rewrite;
expires max;
log_not_found off;
@ -45,19 +56,19 @@ http {
rewrite ^ /index.php;
}
# Don't allow direct access to PHP files in the vendor directory
# Don't allow direct access to PHP files in the vendor directory.
location ~ /vendor/.*\.php$ {
deny all;
return 404;
}
# Protect files and directories from prying eyes
# Protect files and directories from prying eyes.
location ~* \.(engine|inc|install|make|module|profile|po|sh|.*sql|theme|twig|tpl(\.php)?|xtmpl|yml)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^(\.(?!well-known).*|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)$|^#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$ {
deny all;
return 404;
}
# Protect .git directory
# Protect .git directory.
location ~ /\.git {
deny all;
return 404;

View file

@ -0,0 +1,68 @@
services:
# Docker-in-Docker backend so CI jobs run in isolated containers,
# not against the host Docker daemon.
docker-in-docker:
image: docker:dind
container_name: forgejo-runner-dind
privileged: true
environment:
DOCKER_TLS_CERTDIR: /certs
volumes:
- runner-certs:/certs/client
- forgejo-runner-dind:/var/lib/docker
networks:
forgejo-runner:
# DinD's TLS cert is issued for the SAN "docker"; alias so the
# runner can verify the cert when connecting over TLS.
aliases:
- docker
restart: unless-stopped
forgejo-runner:
image: code.forgejo.org/forgejo/runner:${RUNNER_VERSION:-6}
container_name: forgejo-runner
environment:
DOCKER_HOST: tcp://docker:2376
DOCKER_CERT_PATH: /certs/client
DOCKER_TLS_VERIFY: "1"
FORGEJO_INSTANCE_URL: ${FORGEJO_INSTANCE_URL}
FORGEJO_RUNNER_TOKEN: ${FORGEJO_RUNNER_TOKEN}
FORGEJO_RUNNER_NAME: ${FORGEJO_RUNNER_NAME:-forgejo-runner}
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
if [ ! -f /data/.runner ]; then
echo "Registering runner with ${FORGEJO_INSTANCE_URL} ..."
forgejo-runner register --no-interactive \
--instance "${FORGEJO_INSTANCE_URL}" \
--token "${FORGEJO_RUNNER_TOKEN}" \
--name "${FORGEJO_RUNNER_NAME}" \
--labels "docker:docker://node:20-bookworm,ubuntu-latest:docker://node:20-bookworm"
fi
exec forgejo-runner daemon
working_dir: /data
volumes:
- forgejo-runner-data:/data
- runner-certs:/certs/client:ro
networks:
- forgejo-runner
- traefik
depends_on:
- docker-in-docker
restart: unless-stopped
volumes:
forgejo-runner-data:
name: forgejo-runner-data
forgejo-runner-dind:
name: forgejo-runner-dind
runner-certs:
name: forgejo-runner-certs
networks:
forgejo-runner:
name: forgejo-runner
traefik:
name: traefik
external: true

View file

@ -0,0 +1,58 @@
services:
# Forgejo git forge (git.nasarek.dev). Replaces GitLab.
forgejo:
image: codeberg.org/forgejo/forgejo:${FORGEJO_VERSION:-11}
container_name: forgejo
environment:
USER_UID: 1000
USER_GID: 1000
FORGEJO__database__DB_TYPE: postgres
FORGEJO__database__HOST: postgres:5432
FORGEJO__database__NAME: ${FORGEJO_DB_NAME}
FORGEJO__database__USER: ${FORGEJO_DB_USER}
FORGEJO__database__PASSWD: ${FORGEJO_DB_PASSWORD}
FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN}
FORGEJO__server__ROOT_URL: https://${FORGEJO_DOMAIN}/
FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN}
FORGEJO__server__HTTP_PORT: "3000"
# Advertised in clone URLs (Traefik forgejo-ssh entrypoint, formerly GitLab's port).
FORGEJO__server__SSH_PORT: "2424"
FORGEJO__server__SSH_LISTEN_PORT: "22"
FORGEJO__actions__ENABLED: "true"
# Skip the web installer; auto-migrate against Postgres on boot.
FORGEJO__security__INSTALL_LOCK: "true"
FORGEJO__service__DISABLE_REGISTRATION: "true"
labels:
- traefik.enable=true
- traefik.docker.network=traefik
# HTTP
- traefik.http.routers.forgejo.rule=Host(`${FORGEJO_DOMAIN}`)
- traefik.http.routers.forgejo.entrypoints=web,websecure
- traefik.http.routers.forgejo.middlewares=https-redirect
- traefik.http.routers.forgejo.tls=true
- traefik.http.routers.forgejo.tls.certresolver=le
- traefik.http.services.forgejo.loadbalancer.server.port=3000
# SSH over dedicated Traefik TCP entrypoint (port 2424)
- "traefik.tcp.routers.forgejo-ssh.rule=HostSNI(`*`)"
- "traefik.tcp.routers.forgejo-ssh.entrypoints=forgejo-ssh"
- "traefik.tcp.services.forgejo-ssh.loadbalancer.server.port=22"
volumes:
- forgejo-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- traefik
- database
restart: unless-stopped
volumes:
forgejo-data:
name: forgejo-data
networks:
traefik:
name: traefik
external: true
database:
name: database
external: true

82
forgejo/migrate-from-gitlab.sh Executable file
View file

@ -0,0 +1,82 @@
#!/bin/bash
#
# Bulk-migrate every GitLab project into Forgejo with full data
# (code, issues, merge requests, labels, milestones, releases, wiki).
#
# Runs both forges in parallel — GitLab is only read, never modified.
#
# Usage:
# export GITLAB_URL=https://gitlab.nasarek.dev
# export GITLAB_TOKEN=glpat-xxxxxxxx # scopes: read_api, read_repository
# export FORGEJO_URL=https://git.nasarek.dev
# export FORGEJO_TOKEN=xxxxxxxx # Forgejo app token, repo + org scope
# export FORGEJO_OWNER=root # target user/org that will own the repos
# ./migrate-from-gitlab.sh [--dry-run]
#
set -euo pipefail
DRY_RUN=false
[ "${1:-}" = "--dry-run" ] && DRY_RUN=true
: "${GITLAB_URL:?set GITLAB_URL}"
: "${GITLAB_TOKEN:?set GITLAB_TOKEN}"
: "${FORGEJO_URL:?set FORGEJO_URL}"
: "${FORGEJO_TOKEN:?set FORGEJO_TOKEN}"
: "${FORGEJO_OWNER:?set FORGEJO_OWNER (target user/org)}"
command -v jq >/dev/null || { echo "jq is required"; exit 1; }
# Resolve the Forgejo owner's numeric uid (repos/migrate needs repo_owner name + uid).
OWNER_UID=$(curl -fsS -H "Authorization: token ${FORGEJO_TOKEN}" \
"${FORGEJO_URL}/api/v1/users/${FORGEJO_OWNER}" | jq -r '.id')
echo "Target owner: ${FORGEJO_OWNER} (uid=${OWNER_UID})"
migrate_one() {
local clone_url="$1" name="$2" private="$3" desc="$4"
echo "==> ${name} (private=${private})"
if $DRY_RUN; then return 0; fi
local payload
payload=$(jq -n \
--arg addr "$clone_url" --arg token "$GITLAB_TOKEN" \
--arg name "$name" --arg owner "$FORGEJO_OWNER" \
--argjson uid "$OWNER_UID" --argjson private "$private" \
--arg desc "$desc" \
'{clone_addr:$addr, service:"gitlab", auth_token:$token,
repo_name:$name, repo_owner:$owner, uid:$uid,
private:$private, description:$desc,
issues:true, pull_requests:true, labels:true,
milestones:true, releases:true, wiki:true}')
local code
code=$(curl -s -o /tmp/forgejo_migrate_resp.json -w '%{http_code}' \
-H "Authorization: token ${FORGEJO_TOKEN}" -H "Content-Type: application/json" \
-X POST "${FORGEJO_URL}/api/v1/repos/migrate" -d "$payload")
if [ "$code" = "201" ]; then
echo " OK"
elif [ "$code" = "409" ]; then
echo " SKIP (already exists)"
else
echo " FAILED (HTTP $code): $(jq -r '.message // .' /tmp/forgejo_migrate_resp.json)"
fi
}
# Page through all GitLab projects the token can see.
page=1
while :; do
resp=$(curl -fsS -H "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"${GITLAB_URL}/api/v4/projects?membership=true&per_page=100&page=${page}&simple=false")
count=$(echo "$resp" | jq 'length')
[ "$count" -eq 0 ] && break
while IFS=$'\t' read -r http_url repo_path visibility description; do
private=true; [ "$visibility" = "public" ] && private=false
# Use the bare repo slug (e.g. "dfg_3dviewer_embed"), dropping the GitLab namespace.
migrate_one "$http_url" "$repo_path" "$private" "${description:-}"
done < <(echo "$resp" | jq -r '.[] | [.http_url_to_repo, .path, .visibility, (.description // "")] | @tsv')
page=$((page + 1))
done
echo "Done."

View file

@ -1,54 +0,0 @@
services:
gitlab:
image: gitlab/gitlab-ce:17.8.6-ce.0
container_name: gitlab
hostname: '${GITLAB_DOMAIN}'
environment:
GITLAB_OMNIBUS_CONFIG: |
# Add any other gitlab.rb configuration here, each on its own line
external_url 'https://${GITLAB_DOMAIN}'
gitlab_rails['gitlab_shell_ssh_port'] = 2424
# We need to tell GitLab to use SSH port 22 internally
gitlab_shell['auth_file'] = "/var/opt/gitlab/.ssh/authorized_keys"
#gitlab_shell['ssh_port'] = 22
nginx['listen_port'] = 80
nginx['listen_https'] = false
nginx['proxy_set_headers'] = {
"X-Forwarded-Proto" => "https",
"X-Forwarded-Ssl" => "on"
}
labels:
- traefik.enable=true
- traefik.docker.network=traefik
# HTTP configuration
- traefik.http.routers.gitlab.entrypoints=web,websecure
- traefik.http.routers.gitlab.tls=true
- traefik.http.routers.gitlab.tls.certresolver=le
- traefik.http.routers.gitlab.rule=Host(`${GITLAB_DOMAIN}`)
- traefik.http.services.gitlab.loadbalancer.server.port=80
# TCP/SSH configuration - completely revised
- "traefik.tcp.routers.gitlab-ssh.rule=HostSNI(`*`)"
- "traefik.tcp.routers.gitlab-ssh.entrypoints=gitlab-ssh"
- "traefik.tcp.services.gitlab-ssh.loadbalancer.server.port=22"
volumes:
- 'gitlab-config:/etc/gitlab'
- 'gitlab-logs:/var/log/gitlab'
- 'gitlab-data:/var/opt/gitlab'
shm_size: '256m'
networks:
- traefik
restart: unless-stopped
volumes:
gitlab-config:
name: gitlab-config
gitlab-logs:
name: gitlab-logs
gitlab-data:
name: gitlab-data
networks:
traefik:
name: traefik
external: true

View file

@ -1,8 +1,9 @@
services:
hedgedoc:
# Make sure to use the latest release from https://hedgedoc.org/latest-release
image: quay.io/hedgedoc/hedgedoc:1.10.2
image: quay.io/hedgedoc/hedgedoc:1.10.3
container_name: hedgedoc
restart: unless-stopped
environment:
- CMD_DB_URL=postgres://${HEDGEDOC_DB_USER}:${HEDGEDOC_DB_PASSWORD}@${HEDGEDOC_DB_HOST}:${HEDGEDOC_DB_PORT}/${HEDGEDOC_DB_NAME}
- CMD_DOMAIN=${HEDGEDOC_DOMAIN}

1
mailcow/.gitignore vendored
View file

@ -75,3 +75,4 @@ refresh_images.sh
update_diffs/
create_cold_standby.sh
!data/conf/nginx/mailcow_auth.conf
data/conf/postfix/postfix-tlspol

View file

@ -1,11 +1,11 @@
# Contribution Guidelines
**_Last modified on 15th August 2024_**
**_Last modified on 12th November 2025_**
First of all, thank you for wanting to provide a bugfix or a new feature for the mailcow community, it's because of your help that the project can continue to grow!
As we want to keep mailcow's development structured we setup these Guidelines which helps you to create your issue/pull request accordingly.
**PLEASE NOTE, THAT WE MIGHT CLOSE ISSUES/PULL REQUESTS IF THEY DON'T FULLFIL OUR WRITTEN GUIDELINES WRITTEN INSIDE THIS DOCUMENT**. So please check this guidelines before you propose a Issue/Pull Request.
**PLEASE NOTE, THAT WE WILL CLOSE ISSUES/PULL REQUESTS IF THEY DON'T FULFILL OUR WRITTEN GUIDELINES WRITTEN INSIDE THIS DOCUMENT**. So please check this guidelines before you propose a Issue/Pull Request.
## Topics
@ -27,14 +27,18 @@ However, please note the following regarding pull requests:
6. Please **ALWAYS** create the actual pull request against the staging branch and **NEVER** directly against the master branch. *If you forget to do this, our moobot will remind you to switch the branch to staging.*
7. Wait for a merge commit: It may happen that we do not accept your pull request immediately or sometimes not at all for various reasons. Please do not be disappointed if this is the case. We always endeavor to incorporate any meaningful changes from the community into the mailcow project.
8. If you are planning larger and therefore more complex pull requests, it would be advisable to first announce this in a separate issue and then start implementing it after the idea has been accepted in order to avoid unnecessary frustration and effort!
9. If your PR requires a Docker image rebuild (changes to Dockerfiles or files in data/Dockerfiles/), update the image tag in docker-compose.yml. Use the base-image versioning (e.g. ghcr.io/mailcow/sogo:5.12.4 → :5.12.5 for version bumps; append a letter for patch fixes, e.g. :5.12.4a). Follow this scheme.
---
## Issue Reporting
**_Last modified on 15th August 2024_**
**_Last modified on 12th November 2025_**
If you plan to report a issue within mailcow please read and understand the following rules:
### Security disclosures / Security-related fixes
- Security vulnerabilities and security fixes must always be reported confidentially first to the contact address specified in SECURITY.md before they are integrated, published, or publicly disclosed in issues/PRs. Please wait for a response from the specified contact to ensure coordinated and responsible disclosure.
### Issue Reporting Guidelines
1. **ONLY** use the issue tracker for bug reports or improvement requests and NOT for support questions. For support questions you can either contact the [mailcow community on Telegram](https://docs.mailcow.email/#community-support-and-chat) or the mailcow team directly in exchange for a [support fee](https://docs.mailcow.email/#commercial-support).

View file

@ -0,0 +1,230 @@
#!/usr/bin/env bash
# _modules/scripts/core.sh
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
# ANSI color for red errors
RED='\e[31m'
GREEN='\e[32m'
YELLOW='\e[33m'
BLUE='\e[34m'
MAGENTA='\e[35m'
LIGHT_RED='\e[91m'
LIGHT_GREEN='\e[92m'
NC='\e[0m'
caller="${BASH_SOURCE[1]##*/}"
get_installed_tools(){
for bin in openssl curl docker git awk sha1sum grep cut jq; do
if [[ -z $(command -v ${bin}) ]]; then
echo "Error: Cannot find command '${bin}'. Cannot proceed."
echo "Solution: Please review system requirements and install requirements. Then, re-run the script."
echo "See System Requirements: https://docs.mailcow.email/getstarted/install/"
echo "Exiting..."
exit 1
fi
done
if grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox grep detected, please install gnu grep, \"apk add --no-cache --upgrade grep\"${NC}"; exit 1; fi
# This will also cover sort
if cp --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox cp detected, please install coreutils, \"apk add --no-cache --upgrade coreutils\"${NC}"; exit 1; fi
if sed --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox sed detected, please install gnu sed, \"apk add --no-cache --upgrade sed\"${NC}"; exit 1; fi
}
get_docker_version(){
# Check Docker Version (need at least 24.X)
docker_version=$(docker version --format '{{.Server.Version}}' | cut -d '.' -f 1)
}
get_compose_type(){
if docker compose > /dev/null 2>&1; then
if docker compose version --short | grep -e "^[2-9]\." -e "^v[2-9]\." -e "^[1-9][0-9]\." -e "^v[1-9][0-9]\." > /dev/null 2>&1; then
COMPOSE_VERSION=native
COMPOSE_COMMAND="docker compose"
if [[ "$caller" == "update.sh" ]]; then
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=native/' "$SCRIPT_DIR/mailcow.conf"
fi
echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
sleep 2
echo -e "\e[33mNotice: You'll have to update this Compose Version via your Package Manager manually!\e[0m"
else
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
elif docker-compose > /dev/null 2>&1; then
if ! [[ $(alias docker-compose 2> /dev/null) ]] ; then
if docker-compose version --short | grep -e "^[2-9]\." -e "^[1-9][0-9]\." > /dev/null 2>&1; then
COMPOSE_VERSION=standalone
COMPOSE_COMMAND="docker-compose"
if [[ "$caller" == "update.sh" ]]; then
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=standalone/' "$SCRIPT_DIR/mailcow.conf"
fi
echo -e "\e[33mFound Docker Compose Standalone.\e[0m"
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
sleep 2
echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
else
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
echo -e "\e[31mPlease update/install manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
fi
else
echo -e "\e[31mCannot find Docker Compose.\e[0m"
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
}
detect_bad_asn() {
echo -e "\e[33mDetecting if your IP is listed on Spamhaus Bad ASN List...\e[0m"
response=$(curl --connect-timeout 15 --max-time 30 -s -o /dev/null -w "%{http_code}" "https://asn-check.mailcow.email")
if [ "$response" -eq 503 ]; then
if [ -z "$SPAMHAUS_DQS_KEY" ]; then
echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
echo -e "\e[33mmailcow did not detected a value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf!\e[0m"
sleep 2
echo ""
echo -e "\e[33mTo use the Spamhaus DNS Blocklists again, you will need to create a FREE account for their Data Query Service (DQS) at: https://www.spamhaus.com/free-trial/sign-up-for-a-free-data-query-service-account\e[0m"
echo -e "\e[33mOnce done, enter your DQS API key in mailcow.conf and mailcow will do the rest for you!\e[0m"
echo ""
sleep 2
else
echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
echo -e "\e[32mmailcow detected a Value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf. Postfix will use DQS with the given API key...\e[0m"
fi
elif [ "$response" -eq 200 ]; then
echo -e "\e[33mCheck completed! Your IP is \e[32mclean\e[0m"
elif [ "$response" -eq 429 ]; then
echo -e "\e[33mCheck completed! \e[31mYour IP seems to be rate limited on the ASN Check service... please try again later!\e[0m"
else
echo -e "\e[31mCheck failed! \e[0mMaybe a DNS or Network problem?\e[0m"
fi
}
check_online_status() {
CHECK_ONLINE_DOMAINS=('https://github.com' 'https://hub.docker.com')
for domain in "${CHECK_ONLINE_DOMAINS[@]}"; do
if timeout 6 curl --head --silent --output /dev/null ${domain}; then
return 0
fi
done
return 1
}
prefetch_images() {
[[ -z ${BRANCH} ]] && { echo -e "\e[33m\nUnknown branch...\e[0m"; exit 1; }
git fetch origin #${BRANCH}
while read image; do
RET_C=0
until docker pull "${image}"; do
RET_C=$((RET_C + 1))
echo -e "\e[33m\nError pulling $image, retrying...\e[0m"
[ ${RET_C} -gt 3 ] && { echo -e "\e[31m\nToo many failed retries, exiting\e[0m"; exit 1; }
sleep 1
done
done < <(git show "origin/${BRANCH}:docker-compose.yml" | grep "image:" | awk '{ gsub("image:","", $3); print $2 }')
}
docker_garbage() {
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../.." && pwd )"
IMGS_TO_DELETE=()
declare -A IMAGES_INFO
COMPOSE_IMAGES=($(grep -oP "image: \K(ghcr\.io/)?mailcow.+" "${SCRIPT_DIR}/docker-compose.yml"))
for existing_image in $(docker images --format "{{.ID}}:{{.Repository}}:{{.Tag}}" | grep -E '(mailcow/|ghcr\.io/mailcow/)'); do
ID=$(echo "$existing_image" | cut -d ':' -f 1)
REPOSITORY=$(echo "$existing_image" | cut -d ':' -f 2)
TAG=$(echo "$existing_image" | cut -d ':' -f 3)
if [[ "$REPOSITORY" == "mailcow/backup" || "$REPOSITORY" == "ghcr.io/mailcow/backup" ]]; then
if [[ "$TAG" != "<none>" ]]; then
continue
fi
fi
if [[ " ${COMPOSE_IMAGES[@]} " =~ " ${REPOSITORY}:${TAG} " ]]; then
continue
else
IMGS_TO_DELETE+=("$ID")
IMAGES_INFO["$ID"]="$REPOSITORY:$TAG"
fi
done
if [[ ! -z ${IMGS_TO_DELETE[*]} ]]; then
echo "The following unused mailcow images were found:"
for id in "${IMGS_TO_DELETE[@]}"; do
echo " ${IMAGES_INFO[$id]} ($id)"
done
if [ -z "$FORCE" ]; then
read -r -p "Do you want to delete them to free up some space? [y/N] " response
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
docker rmi ${IMGS_TO_DELETE[*]}
else
echo "OK, skipped."
fi
else
echo "Running in forced mode! Force removing old mailcow images..."
docker rmi ${IMGS_TO_DELETE[*]}
fi
echo -e "\e[32mFurther cleanup...\e[0m"
echo "If you want to cleanup further garbage collected by Docker, please make sure all containers are up and running before cleaning your system by executing \"docker system prune\""
fi
}
in_array() {
local e match="$1"
shift
for e; do [[ "$e" == "$match" ]] && return 0; done
return 1
}
detect_major_update() {
if [ ${BRANCH} == "master" ]; then
# Array with major versions
# Add major versions here
MAJOR_VERSIONS=(
"2025-02"
"2025-03"
"2025-09"
)
current_version=""
if [[ -f "${SCRIPT_DIR}/data/web/inc/app_info.inc.php" ]]; then
current_version=$(grep 'MAILCOW_GIT_VERSION' ${SCRIPT_DIR}/data/web/inc/app_info.inc.php | sed -E 's/.*MAILCOW_GIT_VERSION="([^"]+)".*/\1/')
fi
if [[ -z "$current_version" ]]; then
return 1
fi
release_url="https://github.com/mailcow/mailcow-dockerized/releases/tag"
updates_to_apply=()
for version in "${MAJOR_VERSIONS[@]}"; do
if [[ "$current_version" < "$version" ]]; then
updates_to_apply+=("$version")
fi
done
if [[ ${#updates_to_apply[@]} -gt 0 ]]; then
echo -e "\e[33m\nMAJOR UPDATES to be applied:\e[0m"
for update in "${updates_to_apply[@]}"; do
echo "$update - $release_url/$update"
done
echo -e "\nPlease read the release notes before proceeding."
read -p "Do you want to proceed with the update? [y/n] " response
if [[ "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "Proceeding with the update..."
else
echo "Update canceled. Exiting."
exit 1
fi
fi
fi
}

Some files were not shown because too many files have changed in this diff Show more