commit 5c2ac5f7e1b074a02585ff4df6a10975fdcb92d4 Author: Claude Date: Tue Jul 21 11:48:50 2026 -0500 Initial diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dac3c04 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +NODE_ENV=development +PORT=3000 +APP_BASE_URL=http://localhost:3000 +PRIMARY_DOMAIN=debbiewindlerseamstress.com +SECONDARY_DOMAIN=debbiewindler.com + +# Generate a long random value before production. +SESSION_SECRET=replace-with-a-long-random-secret + +# SQLite and local file storage. +DATABASE_PATH=./storage/site.sqlite +UPLOAD_DIR=./public/uploads +BACKUP_DIR=./storage/backups + +# SMTP email notification settings. Messages are still stored if email fails. +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM="Debbie Windler Seamstress " +OWNER_EMAIL= + +# Optional future CAPTCHA support. +CAPTCHA_ENABLED=false +CAPTCHA_SITE_KEY= +CAPTCHA_SECRET_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c537fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.env +storage/*.sqlite +storage/*.sqlite-shm +storage/*.sqlite-wal +storage/backups/*.zip +public/uploads/* +!public/uploads/.gitkeep +!public/uploads/sample-hero.png +npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7cd29ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:24-bookworm-slim + +WORKDIR /app +ENV NODE_ENV=production + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY . . +RUN mkdir -p storage/backups public/uploads + +EXPOSE 3000 +CMD ["node", "--no-warnings", "src/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5483083 --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# Debbie Windler Seamstress Website + +Self-hosted website and owner administration system for `debbiewindlerseamstress.com`. + +This project is intentionally a small, understandable Node.js application with Express, SQLite, session login, local image storage, Docker support, backups, and a nontechnical owner backend. It does not include checkout, customer accounts, public comments, customer uploads, payments, or scheduling. + +## What Was Built + +- Four public pages: Home, Items, Services, About and Contact. +- Full-screen editable hero with replaceable background image, overlay, position, text, and buttons. +- Editable featured/new/sale item showcase. +- Alphabetical item browsing with search and filters. +- Editable services page. +- Editable about text and equipment list. +- Website contact form that stores messages in SQLite even when email notification is not configured or fails. +- Secure first-owner setup flow at `/admin/setup`. +- Owner login at `/admin`. +- Admin dashboard, page editor, item manager, service manager, about/equipment manager, messages inbox, media library, appearance settings, navigation settings, business settings, search/sharing settings, security settings, backups, and maintenance mode. +- Local image upload validation and WebP optimization through Sharp. +- Manual downloadable backup archives for the database and uploaded images. +- Docker and Docker Compose support. +- Sample content and one sample sewing-themed hero image that should be replaced with Debbie's real artwork/photos. + +## Project Location + +`C:\Users\Owner\Documents\UO private Server\DebbieWindlerSeamstress` + +## Local Development + +Install Node.js 24 or newer, then from this folder: + +```powershell +pnpm install +pnpm run init-db +pnpm run dev +``` + +Open: + +```text +http://localhost:3000 +``` + +If `pnpm` is not installed globally, `npm install` and `npm run dev` also work. + +## First Owner Setup + +1. Start the site. +2. Open `http://localhost:3000/admin`. +3. The app will redirect to `/admin/setup` until the first owner account is created. +4. Enter owner name, email, and a password with at least 10 characters. +5. After setup, future visits to `/admin` use the owner login screen. + +No administrator password is hardcoded or stored as plain text. + +## Common Owner Tasks + +Edit the home page: + +1. Log in at `/admin`. +2. Choose `Home Page`. +3. Change hero text, background URL, overlay, buttons, and showcase text. +4. Use `Preview Home Page`. + +Add an item: + +1. Choose `Items`. +2. Choose `Add Item`. +3. Fill in name, descriptions, category, price notes, availability, badges, and publish setting. +4. Upload one or more item images. +5. Save. + +Read a customer message: + +1. Choose `Messages`. +2. Open a message. +3. Use `Reply by Email` to open the computer's normal email program. +4. Mark unread, archive, or delete as needed. + +Create a backup: + +1. Choose `Backups`. +2. Choose `Create Manual Backup`. +3. Download the created ZIP file. + +## Environment Setup + +Copy `.env.example` to `.env` for local or production configuration. + +Important values: + +- `PORT`: app port, default `3000`. +- `APP_BASE_URL`: final public URL, for example `https://debbiewindlerseamstress.com`. +- `SESSION_SECRET`: long random secret for sessions. +- `DATABASE_PATH`: SQLite file path. +- `UPLOAD_DIR`: uploaded image folder. +- `BACKUP_DIR`: backup output folder. +- `OWNER_EMAIL`: where contact form notifications go. +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`: email notification settings. + +Messages are always stored in the database even when SMTP is empty or fails. + +## Docker + +Create `.env`, then run: + +```powershell +docker compose up -d --build +``` + +The app stores persistent files in: + +- `./storage` +- `./public/uploads` + +Do not include `.env` in downloadable backups or source control. + +## Production Hosting Notes + +Recommended production shape: + +1. Run the app with Docker Compose on Perry's server. +2. Put a reverse proxy such as Caddy, Nginx, or IIS ARR in front of it. +3. Configure TLS for `debbiewindlerseamstress.com`. +4. Later, redirect `debbiewindler.com` to `debbiewindlerseamstress.com`. +5. Set `NODE_ENV=production`, `APP_BASE_URL`, `SESSION_SECRET`, SMTP values, and owner email in `.env`. + +This project does not make DNS, router, firewall, reverse proxy, or live production changes. + +## Backup and Restore + +Manual backups are created from `/admin/backups`. + +Backups include: + +- SQLite database. +- Uploaded images. + +Backups exclude: + +- `.env`. +- passwords and SMTP secrets outside the database. + +Restore process: + +1. Stop the app. +2. Make a copy of the current `storage` and `public/uploads` folders. +3. Extract the backup ZIP. +4. Replace `storage/site.sqlite` and restore the `uploads` folder. +5. Start the app and check `/healthz`. + +Use strong confirmation before replacing production files. + +## Testing + +Run: + +```powershell +pnpm test +``` + +Current smoke tests verify: + +- Public pages load. +- Admin redirects to setup/login. +- Contact form validation and CSRF session behavior. + +Manual checklist before going live: + +- Create owner account. +- Log in and log out. +- Edit the home hero and preview. +- Upload an image and confirm it appears in the media library. +- Add an item and confirm `/items` stays alphabetical. +- Add a service and reorder it. +- Edit About and Equipment entries. +- Submit a contact form message. +- Confirm the message appears in the admin inbox. +- Configure SMTP and confirm email notifications. +- Confirm messages are still stored if SMTP is disabled. +- Create and download a backup. +- Enable maintenance mode and confirm admins can still log in. +- Check phone, tablet, and desktop widths. + +## Security Checklist + +- Set a long `SESSION_SECRET`. +- Use HTTPS in production. +- Keep `.env` private. +- Use a strong owner password. +- Keep Docker base images and npm packages updated. +- Back up before updates. +- Do not expose `public/uploads` as executable content. +- Do not add customer file uploads without a separate security review. +- Keep private home address hidden unless Debbie deliberately chooses to publish it. + +## Still Needed From Owner + +- Final Debbie business wording. +- Final email address and phone number. +- Facebook page link, if desired. +- Service area and business hours. +- Final photos, artwork, logo, favicon, and business card artwork. +- Real service descriptions and pricing notes. +- Real item listings and item photos. +- SMTP provider settings. +- Decision on whether `debbiewindler.com` should redirect at the reverse proxy or DNS/hosting layer later. + +## Notes About Initial Version + +This is a complete working first version, but a few advanced features are intentionally conservative: + +- CAPTCHA is wired as a future setting but not enabled by default. +- Revision history is stored for important edits; a polished one-click restore screen can be expanded later. +- The page editor uses controlled section layouts instead of arbitrary code or drag-and-drop. +- The owner reply flow uses `mailto:` instead of a built-in outgoing email client. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..55a5937 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + debbie-windler-seamstress: + build: . + container_name: debbie-windler-seamstress + restart: unless-stopped + env_file: .env + ports: + - "${PORT:-3000}:3000" + volumes: + - ./storage:/app/storage + - ./public/uploads:/app/public/uploads + healthcheck: + test: ["CMD", "node", "--no-warnings", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/package.json b/package.json new file mode 100644 index 0000000..7d1af7f --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "debbie-windler-seamstress", + "version": "1.0.0", + "description": "Self-hosted website and owner administration system for Debbie Windler Seamstress.", + "main": "src/server.js", + "scripts": { + "start": "node --no-warnings src/server.js", + "dev": "node --no-warnings src/server.js", + "init-db": "node --no-warnings src/database.js", + "backup": "node --no-warnings src/backup.js", + "test": "node --no-warnings --test" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "archiver": "^7.0.1", + "bcryptjs": "^2.4.3", + "express": "^4.19.2", + "express-session": "^1.18.1", + "multer": "^2.2.0", + "nodemailer": "^6.9.14", + "sanitize-html": "^2.13.0", + "sharp": "^0.33.5", + "slugify": "^1.6.6" + }, + "devDependencies": { + "supertest": "^7.0.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..cb1433c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1930 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + archiver: + specifier: ^7.0.1 + version: 7.0.1 + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 + express: + specifier: ^4.19.2 + version: 4.22.2 + express-session: + specifier: ^1.18.1 + version: 1.19.0 + multer: + specifier: ^2.2.0 + version: 2.2.0 + nodemailer: + specifier: ^6.9.14 + version: 6.10.1 + sanitize-html: + specifier: ^2.13.0 + version: 2.17.6 + sharp: + specifier: ^0.33.5 + version: 0.33.5 + slugify: + specifier: ^1.6.6 + version: 1.6.9 + devDependencies: + supertest: + specifier: ^7.0.0 + version: 7.2.2 + +packages: + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + + body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + express-session@1.19.0: + resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==} + engines: {node: '>= 0.8.0'} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + launder@1.7.1: + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + nodemailer@6.10.1: + resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} + engines: {node: '>=6.0.0'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} + engines: {node: ^10 || ^12 || >=14} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-html@2.17.6: + resolution: {integrity: sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==} + engines: {node: '>=22.12.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + slugify@1.6.9: + resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} + engines: {node: '>=8.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + +snapshots: + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + append-field@1.0.0: {} + + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.0 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + array-flatten@1.1.1: {} + + asap@2.0.6: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + b4a@1.8.1: {} + + balanced-match@1.0.2: {} + + bare-events@2.9.1: {} + + bare-fs@4.7.4: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.5: + dependencies: + bare-path: 3.1.1 + + base64-js@1.5.1: {} + + bcryptjs@2.4.3: {} + + body-parser@1.20.6: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + buffer-crc32@1.0.0: {} + + buffer-from@1.1.2: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dayjs@1.11.21: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge@4.3.1: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@3.0.0: {} + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + entities@8.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + + express-session@1.19.0: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.1.0 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.6 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-fifo@1.3.2: {} + + fast-safe-stringify@2.1.1: {} + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.3.4: {} + + is-fullwidth-code-point@3.0.0: {} + + is-plain-object@5.0.0: {} + + is-stream@2.0.1: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + launder@1.7.1: + dependencies: + dayjs: 1.11.21 + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + lodash@4.18.1: {} + + lru-cache@10.4.3: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mime@2.6.0: {} + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.2 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minipass@7.1.3: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + + nanoid@3.3.16: {} + + negotiator@0.6.3: {} + + nodemailer@6.10.1: {} + + normalize-path@3.0.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + package-json-from-dist@1.0.1: {} + + parse-srcset@1.0.2: {} + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-to-regexp@0.1.13: {} + + picocolors@1.1.1: {} + + postcss@8.5.20: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + random-bytes@1.0.0: {} + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sanitize-html@2.17.6: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 12.0.0 + is-plain-object: 5.0.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.20 + + semver@7.8.5: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + slugify@1.6.9: {} + + source-map-js@1.2.1: {} + + statuses@2.0.2: {} + + streamsearch@1.1.0: {} + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.4 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + toidentifier@1.0.1: {} + + tslib@2.8.1: + optional: true + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + + unpipe@1.0.0: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..4f4e1f0 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + better-sqlite3: set this to true or false + sharp: set this to true or false +onlyBuiltDependencies: + - sharp diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 0000000..ac96bbc --- /dev/null +++ b/public/css/admin.css @@ -0,0 +1,216 @@ +* { box-sizing: border-box; } +body { + margin: 0; + color: #2f2926; + background: #f6f0ea; + font-family: Arial, Helvetica, sans-serif; + line-height: 1.5; +} +a { color: #7d3f45; } +button, .button { + border: 0; + border-radius: 8px; + background: #7d3f45; + color: white; + padding: .72rem .95rem; + text-decoration: none; + font-weight: 800; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; +} +.button.secondary, .link-button { + background: transparent; + color: #7d3f45; + box-shadow: inset 0 0 0 1px #7d3f45; +} +.danger { background: #9b2f2f; } +input, textarea, select { + width: 100%; + border: 1px solid rgba(47,41,38,.22); + border-radius: 8px; + padding: .7rem .8rem; + font: inherit; + background: white; +} +label { display: grid; gap: .35rem; font-weight: 800; } +.checkbox { display: flex; gap: .55rem; align-items: flex-start; } +.checkbox input { width: auto; margin-top: .35rem; } +a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { + outline: 3px solid #7a8f73; + outline-offset: 2px; +} +.skip-link { + position: absolute; + top: -100px; + left: 1rem; + background: #7d3f45; + color: white; + padding: .75rem 1rem; + z-index: 20; +} +.skip-link:focus { top: 1rem; } +.admin-sidebar { + position: fixed; + inset: 0 auto 0 0; + width: 260px; + overflow: auto; + padding: 1rem; + background: #2f2926; + color: white; +} +.admin-sidebar a { color: white; } +.admin-brand { + display: block; + font-family: Georgia, "Times New Roman", serif; + font-size: 1.3rem; + text-decoration: none; + margin-bottom: 1rem; +} +.admin-brand span { font-family: Arial, Helvetica, sans-serif; font-size: .9rem; opacity: .8; } +.admin-sidebar nav { + display: grid; + gap: .25rem; + margin-bottom: 1rem; +} +.admin-sidebar nav a, .admin-sidebar > a, .link-button { + border-radius: 8px; + padding: .58rem .7rem; + text-decoration: none; + text-align: left; +} +.admin-sidebar nav a.active { background: rgba(255,255,255,.16); } +.admin-main { + margin-left: 260px; + padding: clamp(1rem, 4vw, 2rem); +} +.admin-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; +} +.eyebrow { + color: #7a8f73; + text-transform: uppercase; + letter-spacing: .08em; + font-weight: 900; + font-size: .75rem; +} +h1, h2, h3 { + font-family: Georgia, "Times New Roman", serif; + line-height: 1.12; + letter-spacing: 0; +} +h1 { margin: .2rem 0; font-size: clamp(2rem, 5vw, 3.5rem); } +.panel, .table-list article, .stats-grid article, .auth-card, .section-editor { + background: white; + border: 1px solid rgba(47,41,38,.12); + border-radius: 8px; + padding: 1rem; +} +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} +.stats-grid strong { + display: block; + font-size: 1.8rem; + color: #7d3f45; +} +.quick-actions, .toolbar { + display: flex; + flex-wrap: wrap; + gap: .75rem; + margin: 1rem 0; + align-items: center; +} +.two-column { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; +} +.table-list { + display: grid; + gap: 1rem; +} +.editor-form { + display: grid; + gap: 1rem; + max-width: 940px; +} +.editor-form.nested { + margin-top: 1rem; + max-width: none; +} +.inline-form, .inline-grid { + display: grid; + gap: .75rem; + margin: 1rem 0; +} +.inline-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + align-items: end; +} +.section-editor { + margin: .75rem 0; +} +.section-editor summary { + cursor: pointer; + font-weight: 900; +} +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + margin: 1rem 0; +} +.media-card img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + border-radius: 8px; + background: #efe3da; +} +.media-card { + background: white; + border-radius: 8px; + padding: .75rem; + display: grid; + gap: .4rem; +} +.message-detail pre { + white-space: pre-wrap; + background: #f6f0ea; + padding: 1rem; + border-radius: 8px; +} +.auth-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1rem; +} +.auth-card { + width: min(100%, 460px); +} +.auth-card form { + display: grid; + gap: 1rem; +} +@media (max-width: 860px) { + .admin-sidebar { + position: static; + width: auto; + } + .admin-main { + margin-left: 0; + } + .admin-sidebar nav { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } +} diff --git a/public/css/site.css b/public/css/site.css new file mode 100644 index 0000000..e4f5175 --- /dev/null +++ b/public/css/site.css @@ -0,0 +1,320 @@ +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + color: var(--text); + background: var(--bg); + font-family: var(--body-font); + line-height: 1.6; + font-size: 17px; +} +img { max-width: 100%; display: block; } +a { color: var(--link); } +a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 3px; +} +.skip-link { + position: absolute; + top: -100px; + left: 1rem; + background: var(--button); + color: var(--button-text); + padding: .75rem 1rem; + z-index: 20; +} +.skip-link:focus { top: 1rem; } +.site-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: .85rem clamp(1rem, 4vw, 2.5rem); + background: color-mix(in srgb, var(--bg) 94%, white); + border-bottom: 1px solid rgba(52,43,40,.12); + backdrop-filter: blur(12px); +} +.brand { + display: inline-flex; + align-items: center; + gap: .65rem; + text-decoration: none; + color: var(--heading); + font-family: var(--heading-font); + font-weight: 700; +} +.brand-mark { + width: 34px; + height: 34px; + border: 2px solid var(--accent); + border-radius: 50%; + background: + linear-gradient(90deg, transparent 47%, var(--accent) 48% 52%, transparent 53%), + radial-gradient(circle at 50% 50%, var(--bg-alt) 0 38%, transparent 39%); +} +.site-nav { + display: flex; + align-items: center; + gap: .35rem; + flex-wrap: wrap; +} +.site-nav a, .site-footer nav a { + text-decoration: none; + padding: .5rem .7rem; + border-radius: var(--radius); +} +.site-nav a.active { background: var(--bg-alt); } +.nav-cta, .button, button { + appearance: none; + border: 0; + border-radius: var(--radius); + background: var(--button); + color: var(--button-text); + padding: .78rem 1rem; + text-decoration: none; + font-weight: 700; + cursor: pointer; +} +.button.secondary, .site-nav .nav-cta.secondary { + background: transparent; + color: var(--button); + box-shadow: inset 0 0 0 1px var(--button); +} +.button.small { padding: .55rem .7rem; font-size: .92rem; } +.text-button { font-weight: 700; } +.menu-toggle { display: none; } +.hero { + min-height: min(760px, 92vh); + display: grid; + align-items: center; + padding: clamp(4rem, 10vw, 8rem) clamp(1rem, 5vw, 4rem); + background-repeat: no-repeat; + position: relative; +} +.hero::before { + content: ""; + position: absolute; + inset: 0; + background: rgba(255,250,245,var(--overlay-opacity)); +} +.hero.dark::before { background: rgba(0,0,0,var(--overlay-opacity)); } +.hero.none::before { opacity: 0; } +.hero-inner { + position: relative; + max-width: 720px; +} +.eyebrow { + text-transform: uppercase; + letter-spacing: .08em; + color: var(--accent); + font-size: .78rem; + font-weight: 800; +} +h1, h2, h3 { + font-family: var(--heading-font); + color: var(--heading); + line-height: 1.1; + letter-spacing: 0; +} +h1 { font-size: clamp(2.4rem, 8vw, 5.8rem); margin: .25rem 0 1rem; } +h2 { font-size: clamp(1.8rem, 4vw, 3rem); } +h3 { font-size: 1.35rem; } +.hero p { font-size: clamp(1.05rem, 2vw, 1.32rem); max-width: 58ch; } +.hero-contact { font-weight: 700; } +.button-row { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 1.5rem; } +.showcase, .content-section, .filters, .cards-grid, .detail-layout, .gallery, .equipment, .contact-details, .contact-form-section { + padding: clamp(2.5rem, 6vw, 5rem) clamp(1rem, 5vw, 3rem); +} +.section-heading { max-width: var(--site-width); margin: 0 auto 1.5rem; } +.section-heading p { max-width: 68ch; } +.cards-grid { + max-width: var(--site-width); + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; +} +.item-card, .service-card { + background: rgba(255,255,255,.72); + border: 1px solid rgba(52,43,40,.12); + border-radius: var(--radius); + overflow: hidden; + min-width: 0; +} +.item-card img, .service-card img, .image-placeholder { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + background: + repeating-linear-gradient(45deg, rgba(122,143,115,.18) 0 9px, rgba(125,63,69,.12) 9px 18px), + var(--bg-alt); +} +.image-placeholder.thread { + background: + radial-gradient(circle at 30% 35%, rgba(125,63,69,.25) 0 16%, transparent 17%), + radial-gradient(circle at 70% 60%, rgba(122,143,115,.28) 0 18%, transparent 19%), + var(--bg-alt); +} +.card-body { padding: 1rem; } +.badges { display: flex; flex-wrap: wrap; gap: .35rem; min-height: 1.7rem; } +.badges span, .status { + display: inline-block; + border-radius: 999px; + background: var(--bg-alt); + padding: .2rem .55rem; + font-size: .8rem; + font-weight: 700; +} +.price, .pricing-note { font-weight: 800; color: var(--button); } +.card-actions { display: flex; flex-wrap: wrap; gap: .7rem; align-items: center; } +.content-section { + max-width: var(--site-width); + margin: 0 auto; +} +.content-section.split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); + gap: clamp(1rem, 4vw, 3rem); + align-items: center; +} +.content-section.split.reverse { grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); } +.content-section img, .detail-image, .gallery img { + border-radius: var(--radius); + border: 1px solid rgba(52,43,40,.12); +} +.contact-callout { + background: var(--button); + color: var(--button-text); + border-radius: var(--radius); +} +.contact-callout h2 { color: var(--button-text); } +.page-hero { + padding: clamp(3rem, 8vw, 6rem) clamp(1rem, 5vw, 3rem); + background: var(--bg-alt); +} +.page-hero > * { max-width: var(--site-width); margin-left: auto; margin-right: auto; } +.page-hero.compact h1 { font-size: clamp(2.1rem, 6vw, 4.2rem); } +.filters form { + max-width: var(--site-width); + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + align-items: end; +} +label { display: grid; gap: .35rem; font-weight: 700; } +input, textarea, select { + width: 100%; + border: 1px solid rgba(52,43,40,.24); + border-radius: var(--radius); + padding: .75rem .85rem; + font: inherit; + background: white; +} +.alphabet { + max-width: var(--site-width); + margin: 1rem auto 0; + display: flex; + gap: .3rem; + flex-wrap: wrap; +} +.alphabet a { + background: white; + border-radius: var(--radius); + padding: .35rem .55rem; +} +.detail-layout { + max-width: var(--site-width); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(260px, .9fr) minmax(0, 1.1fr); + gap: clamp(1.5rem, 5vw, 4rem); +} +.gallery { + max-width: var(--site-width); + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} +.contact-details { + max-width: var(--site-width); + margin: 0 auto; +} +.contact-details dl { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: .75rem; +} +.contact-details div { + background: white; + border-radius: var(--radius); + padding: 1rem; + border: 1px solid rgba(52,43,40,.12); +} +dt { font-weight: 800; } +dd { margin: .25rem 0 0; } +.contact-form-section { + background: var(--bg-alt); +} +.contact-form { + max-width: 780px; + margin: 0 auto; + display: grid; + gap: 1rem; +} +.checkbox { display: flex; align-items: flex-start; gap: .6rem; } +.checkbox input { width: auto; margin-top: .35rem; } +.hp { position: absolute; left: -9999px; } +.notice.success { + background: #e7f2e4; + border: 1px solid #9abb91; + padding: .75rem 1rem; + border-radius: var(--radius); +} +.site-footer { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + gap: 1.5rem; + padding: 2rem clamp(1rem, 5vw, 3rem); + border-top: 1px solid rgba(52,43,40,.12); + background: color-mix(in srgb, var(--bg-alt) 80%, white); +} +.site-footer nav { display: grid; align-content: start; } +.copyright { grid-column: 1 / -1; font-size: .9rem; } +.maintenance { + min-height: 70vh; + display: grid; + place-items: center; + text-align: center; + padding: 3rem 1rem; +} +.maintenance img { max-width: 360px; border-radius: var(--radius); } +@media (max-width: 760px) { + .menu-toggle { display: inline-flex; } + .site-header { align-items: flex-start; } + .site-nav { + display: none; + width: 100%; + flex-direction: column; + align-items: stretch; + background: var(--bg); + padding: .75rem; + position: absolute; + left: 0; + right: 0; + top: 100%; + border-bottom: 1px solid rgba(52,43,40,.12); + } + .site-nav.open { display: flex; } + .content-section.split, .content-section.split.reverse, .detail-layout, .site-footer { + grid-template-columns: 1fr; + } + .hero { min-height: 82vh; } +} +@media (prefers-reduced-motion: reduce) { + * { scroll-behavior: auto !important; transition: none !important; animation: none !important; } +} diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..e27b987 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,25 @@ +document.querySelectorAll("form").forEach((form) => { + form.addEventListener("submit", (event) => { + const danger = event.submitter?.classList.contains("danger"); + if (danger && !confirm("Please confirm this permanent action.")) { + event.preventDefault(); + return; + } + const button = event.submitter; + if (button && button.tagName === "BUTTON") { + button.dataset.originalText = button.textContent; + button.textContent = "Saving..."; + } + }); +}); + +document.querySelectorAll("input[type='file']").forEach((input) => { + input.addEventListener("change", () => { + const existing = input.parentElement.querySelector(".file-hint"); + existing?.remove(); + const hint = document.createElement("small"); + hint.className = "file-hint"; + hint.textContent = `${input.files.length} selected`; + input.parentElement.appendChild(hint); + }); +}); diff --git a/public/js/site.js b/public/js/site.js new file mode 100644 index 0000000..e3bdb28 --- /dev/null +++ b/public/js/site.js @@ -0,0 +1,19 @@ +document.addEventListener("click", (event) => { + const toggle = event.target.closest("[data-menu-toggle]"); + if (!toggle) return; + const menu = document.getElementById(toggle.getAttribute("aria-controls")); + const open = toggle.getAttribute("aria-expanded") === "true"; + toggle.setAttribute("aria-expanded", String(!open)); + menu?.classList.toggle("open", !open); +}); + +document.querySelectorAll("form").forEach((form) => { + form.addEventListener("submit", () => { + const button = form.querySelector("button[type='submit'], button:not([type])"); + if (button && !button.dataset.keepEnabled) { + button.dataset.originalText = button.textContent; + button.textContent = "Sending..."; + button.disabled = true; + } + }); +}); diff --git a/public/uploads/.gitkeep b/public/uploads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/public/uploads/.gitkeep @@ -0,0 +1 @@ + diff --git a/public/uploads/sample-hero.png b/public/uploads/sample-hero.png new file mode 100644 index 0000000..bd3726e Binary files /dev/null and b/public/uploads/sample-hero.png differ diff --git a/src/backup.js b/src/backup.js new file mode 100644 index 0000000..4c3867c --- /dev/null +++ b/src/backup.js @@ -0,0 +1,11 @@ +const { createBackup } = require("./server"); + +createBackup() + .then((backup) => { + console.log(`Backup created: ${backup.file_path}`); + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..e91a6e1 --- /dev/null +++ b/src/config.js @@ -0,0 +1,41 @@ +const path = require("path"); + +const root = path.resolve(__dirname, ".."); + +function bool(value, fallback = false) { + if (value === undefined || value === null || value === "") return fallback; + return ["1", "true", "yes", "on"].includes(String(value).toLowerCase()); +} + +function fromRoot(value, fallback) { + const selected = value || fallback; + return path.isAbsolute(selected) ? selected : path.join(root, selected); +} + +module.exports = { + root, + env: process.env.NODE_ENV || "development", + port: Number(process.env.PORT || 3000), + baseUrl: process.env.APP_BASE_URL || "http://localhost:3000", + primaryDomain: process.env.PRIMARY_DOMAIN || "debbiewindlerseamstress.com", + secondaryDomain: process.env.SECONDARY_DOMAIN || "debbiewindler.com", + sessionSecret: process.env.SESSION_SECRET || "development-only-change-me", + databasePath: fromRoot(process.env.DATABASE_PATH, "./storage/site.sqlite"), + uploadDir: fromRoot(process.env.UPLOAD_DIR, "./public/uploads"), + backupDir: fromRoot(process.env.BACKUP_DIR, "./storage/backups"), + smtp: { + host: process.env.SMTP_HOST || "", + port: Number(process.env.SMTP_PORT || 587), + secure: bool(process.env.SMTP_SECURE, false), + user: process.env.SMTP_USER || "", + pass: process.env.SMTP_PASS || "", + from: process.env.SMTP_FROM || "Debbie Windler Seamstress ", + ownerEmail: process.env.OWNER_EMAIL || "" + }, + captcha: { + enabled: bool(process.env.CAPTCHA_ENABLED, false), + siteKey: process.env.CAPTCHA_SITE_KEY || "", + secretKey: process.env.CAPTCHA_SECRET_KEY || "" + }, + production: process.env.NODE_ENV === "production" +}; diff --git a/src/database.js b/src/database.js new file mode 100644 index 0000000..6a248dc --- /dev/null +++ b/src/database.js @@ -0,0 +1,471 @@ +const fs = require("fs"); +const path = require("path"); +const { DatabaseSync } = require("node:sqlite"); +const config = require("./config"); +const { now, makeSlug } = require("./utils"); + +fs.mkdirSync(path.dirname(config.databasePath), { recursive: true }); +fs.mkdirSync(config.uploadDir, { recursive: true }); +fs.mkdirSync(config.backupDir, { recursive: true }); + +const db = new DatabaseSync(config.databasePath); +db.exec("PRAGMA journal_mode = WAL"); +db.exec("PRAGMA foreign_keys = ON"); + +function migrate() { + db.exec(` + CREATE TABLE IF NOT EXISTS administrators ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'owner', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT + ); + + CREATE TABLE IF NOT EXISTS sessions ( + sid TEXT PRIMARY KEY, + sess TEXT NOT NULL, + expired_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + subtitle TEXT, + intro TEXT, + header_image_id INTEGER, + background_image_id INTEGER, + meta_title TEXT, + meta_description TEXT, + og_title TEXT, + og_description TEXT, + og_image_id INTEGER, + canonical_url TEXT, + search_visible INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'published', + draft_json TEXT, + published_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(header_image_id) REFERENCES media(id) ON DELETE SET NULL, + FOREIGN KEY(background_image_id) REFERENCES media(id) ON DELETE SET NULL, + FOREIGN KEY(og_image_id) REFERENCES media(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS page_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL, + section_key TEXT, + title TEXT, + body TEXT, + layout TEXT NOT NULL DEFAULT 'text', + image_id INTEGER, + background_image_id INTEGER, + background_color TEXT, + button_label TEXT, + button_url TEXT, + display_order INTEGER NOT NULL DEFAULT 0, + is_published INTEGER NOT NULL DEFAULT 1, + data_json TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(page_id) REFERENCES pages(id) ON DELETE CASCADE, + FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL, + FOREIGN KEY(background_image_id) REFERENCES media(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_name TEXT NOT NULL, + original_name TEXT NOT NULL, + title TEXT, + alt_text TEXT, + category TEXT, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + width INTEGER, + height INTEGER, + url TEXT NOT NULL, + thumb_url TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + display_order INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE + ); + + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + short_description TEXT, + full_description TEXT, + category_id INTEGER, + main_image_id INTEGER, + price TEXT, + starting_price TEXT, + price_range TEXT, + contact_for_pricing INTEGER NOT NULL DEFAULT 0, + quantity TEXT, + availability_status TEXT NOT NULL DEFAULT 'Available', + custom_order_available INTEGER NOT NULL DEFAULT 0, + color_size_notes TEXT, + material_notes TEXT, + care_instructions TEXT, + featured INTEGER NOT NULL DEFAULT 0, + newly_added INTEGER NOT NULL DEFAULT 0, + on_sale INTEGER NOT NULL DEFAULT 0, + sale_text TEXT, + display_order INTEGER NOT NULL DEFAULT 0, + is_published INTEGER NOT NULL DEFAULT 1, + is_archived INTEGER NOT NULL DEFAULT 0, + draft_json TEXT, + date_added TEXT NOT NULL, + published_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL, + FOREIGN KEY(main_image_id) REFERENCES media(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS item_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL, + media_id INTEGER NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + is_main INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS item_tags ( + item_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY(item_id, tag_id), + FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + image_id INTEGER, + short_description TEXT, + full_description TEXT, + pricing_note TEXT, + display_order INTEGER NOT NULL DEFAULT 0, + featured INTEGER NOT NULL DEFAULT 0, + is_published INTEGER NOT NULL DEFAULT 1, + draft_json TEXT, + published_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS equipment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT, + brand TEXT, + model TEXT, + description TEXT, + image_id INTEGER, + display_order INTEGER NOT NULL DEFAULT 0, + is_published INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT, + preferred_contact TEXT, + subject TEXT NOT NULL, + message TEXT NOT NULL, + related_item TEXT, + related_service TEXT, + consent INTEGER NOT NULL DEFAULT 0, + source_page TEXT, + status TEXT NOT NULL DEFAULT 'unread', + spam_status TEXT NOT NULL DEFAULT 'clean', + ip_hash TEXT, + user_agent TEXT, + email_notification_status TEXT NOT NULL DEFAULT 'not_configured', + email_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS navigation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + url TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'internal', + opens_new_tab INTEGER NOT NULL DEFAULT 0, + display_order INTEGER NOT NULL DEFAULT 0, + is_published INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + record_type TEXT NOT NULL, + record_id INTEGER NOT NULL, + title TEXT, + snapshot_json TEXT NOT NULL, + created_by INTEGER, + created_at TEXT NOT NULL, + FOREIGN KEY(created_by) REFERENCES administrators(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_id INTEGER, + action TEXT NOT NULL, + details TEXT, + ip_address TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(admin_id) REFERENCES administrators(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS rate_limits ( + key TEXT PRIMARY KEY, + count INTEGER NOT NULL, + reset_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_items_public ON items(is_published, is_archived, name); + CREATE INDEX IF NOT EXISTS idx_items_flags ON items(featured, newly_added, on_sale); + CREATE INDEX IF NOT EXISTS idx_services_public ON services(is_published, display_order); + CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status, created_at); + CREATE INDEX IF NOT EXISTS idx_page_sections_order ON page_sections(page_id, display_order); + CREATE INDEX IF NOT EXISTS idx_revisions_record ON revisions(record_type, record_id, created_at); + `); +} + +function setSetting(key, value) { + db.prepare(` + INSERT INTO settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `).run(key, String(value ?? ""), now()); +} + +function getSetting(key, fallback = "") { + const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key); + return row ? row.value : fallback; +} + +function allSettings() { + const rows = db.prepare("SELECT key, value FROM settings").all(); + return Object.fromEntries(rows.map((row) => [row.key, row.value])); +} + +function ensureCategory(name) { + const slug = makeSlug(name, "category"); + db.prepare(` + INSERT INTO categories (name, slug, display_order) + VALUES (?, ?, (SELECT COALESCE(MAX(display_order), 0) + 10 FROM categories)) + ON CONFLICT(name) DO NOTHING + `).run(name, slug); + return db.prepare("SELECT id FROM categories WHERE name = ?").get(name).id; +} + +function seed() { + const seeded = getSetting("system.seeded", ""); + if (seeded) return; + + const ts = now(); + const settings = { + "system.seeded": ts, + "business.name": "Debbie Windler Seamstress", + "business.tagline": "Alterations, custom sewing, embroidery, and handmade items", + "business.email": "replace-with-owner-email@example.com", + "business.phone": "", + "business.facebook": "", + "business.service_area": "Service area to be supplied", + "business.hours": "By appointment", + "business.preferred_contact": "Email or website contact form", + "business.footer_text": "Warm, careful sewing work for everyday clothing and special occasions.", + "site.title": "Debbie Windler Seamstress", + "site.description": "Alterations, custom sewing, embroidery, and handmade items.", + "theme.bg": "#fffaf5", + "theme.bg_alt": "#f4e8df", + "theme.text": "#342b28", + "theme.heading": "#2d2522", + "theme.link": "#7d3f45", + "theme.button": "#7d3f45", + "theme.button_text": "#ffffff", + "theme.accent": "#7a8f73", + "theme.radius": "8", + "theme.spacing": "comfortable", + "theme.width": "1120", + "theme.heading_font": "Georgia, 'Times New Roman', serif", + "theme.body_font": "Arial, Helvetica, sans-serif", + "theme.footer_style": "light", + "home.hero.title": "Debbie Windler Seamstress", + "home.hero.subtitle": "Alterations, custom sewing, embroidery, and handmade pieces, with sample wording ready for Debbie to replace.", + "home.hero.contact": "Contact Debbie to talk through your project.", + "home.hero.image": "/uploads/sample-hero.png", + "home.hero.position": "center", + "home.hero.size": "cover", + "home.hero.overlay": "light", + "home.hero.overlay_opacity": "0.28", + "home.hero.text_color": "#2d2522", + "home.hero.button1_label": "Contact Debbie", + "home.hero.button1_url": "/about-contact#contact-form", + "home.hero.button2_label": "View Items", + "home.hero.button2_url": "/items", + "home.highlight.title": "Featured and Newly Added", + "home.highlight.intro": "Sample cards show how items can be featured, marked new, or offered as custom orders.", + "home.highlight.background_color": "#f4e8df", + "maintenance.enabled": "false", + "maintenance.message": "The website is getting a careful refresh. Please check back soon.", + "maintenance.return_note": "", + "maintenance.artwork": "" + }; + for (const [key, value] of Object.entries(settings)) setSetting(key, value); + + const pageInsert = db.prepare(` + INSERT INTO pages (slug, title, subtitle, intro, meta_title, meta_description, status, published_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?) + `); + const pages = [ + ["home", "Home", "Welcome to Debbie Windler Seamstress", "Sample home page copy can be replaced from the owner area."], + ["items", "Items Available and Custom Orders", "Browse sample items alphabetically.", "Items can be available, made to order, on sale, sold as examples, seasonal, or hidden."], + ["services", "Services", "Alterations, repairs, custom sewing, and embroidery.", "Service examples are seeded so Debbie can edit or remove them."], + ["about-contact", "About and Contact", "A personal introduction and the full contact form.", "This page keeps Debbie's contact details editable and does not show a private home address by default."] + ]; + for (const page of pages) pageInsert.run(page[0], page[1], page[2], page[3], page[1], page[3], ts, ts); + + const pageBySlug = db.prepare("SELECT id FROM pages WHERE slug = ?"); + const sectionInsert = db.prepare(` + INSERT INTO page_sections + (page_id, section_key, title, body, layout, display_order, is_published, data_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) + `); + sectionInsert.run(pageBySlug.get("home").id, "intro", "Sewing Services With a Personal Touch", "

This sample introduction can describe Debbie's experience, favorite work, and how customers should reach out.

", "image-right", 10, "{}", ts); + sectionInsert.run(pageBySlug.get("home").id, "contact-callout", "Have a project in mind?", "

Use the contact form to ask about alterations, embroidery, handmade items, or special orders.

", "contact-callout", 30, "{}", ts); + sectionInsert.run(pageBySlug.get("services").id, "service-note", "Not sure what to ask for?", "

Send a note with the garment or project details. Photos can be discussed later by email if needed.

", "contact-callout", 20, "{}", ts); + sectionInsert.run(pageBySlug.get("about-contact").id, "about", "About Debbie", "

Sample biography text: Debbie's real sewing history, experience, and photos can be added here from the owner area.

", "text", 10, "{}", ts); + + const categories = ["Alterations", "Handmade Items", "Embroidery", "Custom Orders", "Seasonal"]; + const categoryIds = Object.fromEntries(categories.map((name) => [name, ensureCategory(name)])); + const itemInsert = db.prepare(` + INSERT INTO items + (name, slug, short_description, full_description, category_id, price, starting_price, price_range, contact_for_pricing, + availability_status, custom_order_available, material_notes, care_instructions, featured, newly_added, on_sale, + sale_text, display_order, is_published, date_added, published_at, updated_at) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?) + `); + const sampleItems = [ + ["Baby Blanket Example", "Soft sample listing for a custom baby blanket.", "

Replace this with true details, sizes, fabrics, and ordering notes.

", "Handmade Items", "", "Contact for starting price", "", 1, "Made to Order", 1, "Fabric details to be supplied.", "Care instructions to be supplied.", 1, 1, 0, "", 10], + ["Embroidered Tote Bag", "Sample tote bag entry for embroidery or custom wording.", "

This is sample content only and can be changed or hidden.

", "Embroidery", "", "", "", 1, "Custom Order", 1, "Canvas or fabric notes can go here.", "Spot clean or care note to be supplied.", 1, 1, 0, "", 20], + ["Formalwear Alteration Example", "A sold/example listing showing previous-style work without checkout.", "

Use sold examples to show the type of work Debbie can discuss with customers.

", "Alterations", "", "", "", 1, "Sold", 0, "", "", 0, 0, 0, "", 30], + ["Seasonal Pillow Cover", "Sample seasonal handmade item.", "

Mark items seasonal, available, hidden, sold, or made to order.

", "Seasonal", "$00 sample", "", "", 0, "Available", 1, "Material notes to be supplied.", "Care instructions to be supplied.", 1, 0, 1, "Sample sale badge", 40] + ]; + for (const item of sampleItems) { + itemInsert.run(item[0], makeSlug(item[0], "item"), item[1], item[2], categoryIds[item[3]], item[4], item[5], item[6], item[7], item[8], item[9], item[10], item[11], item[12], item[13], item[14], item[15], item[16], ts, ts, ts); + } + + const serviceInsert = db.prepare(` + INSERT INTO services + (name, slug, short_description, full_description, pricing_note, display_order, featured, is_published, published_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + `); + [ + ["Clothing Alterations", "Hems, fit adjustments, and everyday garment alterations.", "

Sample service details. Debbie can replace this with her exact services and policies.

", "Pricing depends on the garment and work needed.", 10, 1], + ["Zipper Repair and Replacement", "Repair or replace zippers on clothing and fabric items.", "

Describe accepted items and turnaround once details are known.

", "Contact for pricing.", 20, 1], + ["Wedding Dress Alterations", "Fittings and alterations for wedding dresses and formalwear.", "

Sample text only. Add appointment expectations and timing when ready.

", "Contact early for availability.", 30, 1], + ["Embroidery", "Custom embroidery projects and decorative additions.", "

Describe machine embroidery options, setup needs, and project limits.

", "Contact for pricing.", 40, 1], + ["Baby Blankets", "Handmade or custom baby blanket projects.", "

Describe fabric choices, size options, and custom order timing.

", "Starting price to be supplied.", 50, 0], + ["Patches Sewn Onto Clothing", "Patch placement and sewing for jackets, uniforms, and garments.", "

Describe accepted materials and any preparation instructions.

", "Contact for pricing.", 60, 0] + ].forEach((service) => serviceInsert.run(service[0], makeSlug(service[0], "service"), service[1], service[2], service[3], service[4], service[5], ts, ts)); + + const equipmentInsert = db.prepare(` + INSERT INTO equipment (name, type, brand, model, description, display_order, is_published, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?) + `); + equipmentInsert.run("Industrial Sewing Machine", "Sewing machine", "", "", "Sample equipment entry. Add the real brand, model, and photo when available.", 10, ts); + equipmentInsert.run("Embroidery Machine", "Embroidery machine", "", "", "Sample equipment entry for embroidery work.", 20, ts); + equipmentInsert.run("Thread Rack", "Supplies", "", "", "Sample equipment entry for thread colors and materials.", 30, ts); + + const navInsert = db.prepare("INSERT INTO navigation (label, url, kind, display_order, is_published) VALUES (?, ?, 'internal', ?, 1)"); + [ + ["Home", "/", 10], + ["Items", "/items", 20], + ["Services", "/services", 30], + ["About and Contact", "/about-contact", 40] + ].forEach((nav) => navInsert.run(...nav)); +} + +function saveRevision(type, id, title, snapshot, adminId) { + db.prepare(` + INSERT INTO revisions (record_type, record_id, title, snapshot_json, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(type, id, title || "", JSON.stringify(snapshot), adminId || null, now()); + + const old = db.prepare(` + SELECT id FROM revisions + WHERE record_type = ? AND record_id = ? + ORDER BY created_at DESC + LIMIT -1 OFFSET 10 + `).all(type, id); + if (old.length) { + db.prepare(`DELETE FROM revisions WHERE id IN (${old.map(() => "?").join(",")})`).run(...old.map((row) => row.id)); + } +} + +function audit(adminId, action, details, ipAddress) { + db.prepare(` + INSERT INTO audit_logs (admin_id, action, details, ip_address, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(adminId || null, action, details || "", ipAddress || "", now()); +} + +migrate(); +seed(); + +if (require.main === module) { + console.log(`Database ready at ${config.databasePath}`); +} + +module.exports = { + db, + migrate, + seed, + setSetting, + getSetting, + allSettings, + ensureCategory, + saveRevision, + audit +}; diff --git a/src/render.js b/src/render.js new file mode 100644 index 0000000..f83e45a --- /dev/null +++ b/src/render.js @@ -0,0 +1,280 @@ +const { escapeHtml, cleanRichText, bytes } = require("./utils"); + +function setting(settings, key, fallback = "") { + return settings[key] ?? fallback; +} + +function publicLayout({ title, description, settings, nav = [], body, csrfToken = "", currentPath = "", extraHead = "" }) { + const business = setting(settings, "business.name", "Debbie Windler Seamstress"); + const theme = themeVars(settings); + const navHtml = nav.map((link) => ` + ${escapeHtml(link.label)} + `).join(""); + return ` + + + + + ${escapeHtml(title || setting(settings, "site.title", business))} + + + + + + + + ${extraHead} + + + + +
${body}
+
+
+ ${escapeHtml(business)} +

${escapeHtml(setting(settings, "business.footer_text", ""))}

+
+
+ ${contactLine(settings)} +

${escapeHtml(setting(settings, "business.hours", "By appointment"))}

+
+ + +
+ + + + `; +} + +function adminLayout({ title, body, admin, csrfToken, active = "" }) { + const menu = [ + ["dashboard", "Dashboard", "/admin/dashboard"], + ["home", "Home Page", "/admin/home"], + ["pages", "Pages", "/admin/pages"], + ["items", "Items", "/admin/items"], + ["services", "Services", "/admin/services"], + ["about", "About and Equipment", "/admin/about-equipment"], + ["messages", "Messages", "/admin/messages"], + ["media", "Media Library", "/admin/media"], + ["appearance", "Appearance", "/admin/appearance"], + ["navigation", "Navigation", "/admin/navigation"], + ["business", "Business Settings", "/admin/business"], + ["seo", "Search and Sharing", "/admin/seo"], + ["security", "Security", "/admin/security"], + ["backups", "Backups", "/admin/backups"], + ["maintenance", "Maintenance", "/admin/maintenance"] + ]; + const menuHtml = menu.map(([key, label, url]) => `${label}`).join(""); + return ` + + + + + ${escapeHtml(title)} - Owner Area + + + + + +
+
+
+

Owner administration

+

${escapeHtml(title)}

+
+

${escapeHtml(admin?.name || "")}

+
+ ${body} +
+ + + `; +} + +function authLayout(title, body) { + return ` + + + + + ${escapeHtml(title)} - Debbie Windler Seamstress + + + +
+

${escapeHtml(title)}

+ ${body} +
+ + `; +} + +function themeVars(settings) { + const keys = { + "--bg": ["theme.bg", "#fffaf5"], + "--bg-alt": ["theme.bg_alt", "#f4e8df"], + "--text": ["theme.text", "#342b28"], + "--heading": ["theme.heading", "#2d2522"], + "--link": ["theme.link", "#7d3f45"], + "--button": ["theme.button", "#7d3f45"], + "--button-text": ["theme.button_text", "#ffffff"], + "--accent": ["theme.accent", "#7a8f73"], + "--radius": ["theme.radius", "8"], + "--site-width": ["theme.width", "1120"] + }; + const css = Object.entries(keys).map(([name, [key, fallback]]) => `${name}:${escapeHtml(setting(settings, key, fallback))}${name === "--radius" || name === "--site-width" ? "px" : ""};`); + css.push(`--heading-font:${escapeHtml(setting(settings, "theme.heading_font", "Georgia, 'Times New Roman', serif"))};`); + css.push(`--body-font:${escapeHtml(setting(settings, "theme.body_font", "Arial, Helvetica, sans-serif"))};`); + return css.join(""); +} + +function contactLine(settings) { + const email = setting(settings, "business.email", ""); + const phone = setting(settings, "business.phone", ""); + const facebook = setting(settings, "business.facebook", ""); + return ` + ${email ? `

${escapeHtml(email)}

` : ""} + ${phone ? `

${escapeHtml(phone)}

` : ""} + ${facebook ? `

Facebook

` : ""} + `; +} + +function button(label, href, className = "button") { + if (!label || !href) return ""; + return `${escapeHtml(label)}`; +} + +function itemCard(item) { + const badgeBits = [item.newly_added ? "New" : "", item.on_sale ? "Sale" : "", item.availability_status].filter(Boolean); + return `
+ ${item.image_url ? `${escapeHtml(item.image_alt || item.name)}` : ``} +
+
${badgeBits.map((badge) => `${escapeHtml(badge)}`).join("")}
+

${escapeHtml(item.name)}

+

${escapeHtml(item.short_description || "")}

+

${priceText(item)}

+
+ ${button("View Details", `/items/${item.slug}`, "text-button")} + ${button("Ask About This Item", `/about-contact?item=${encodeURIComponent(item.name)}#contact-form`, "button small")} +
+
+
`; +} + +function serviceCard(service) { + return `
+ ${service.image_url ? `${escapeHtml(service.image_alt || service.name)}` : ``} +
+

${escapeHtml(service.name)}

+

${escapeHtml(service.short_description || "")}

+ ${service.pricing_note ? `

${escapeHtml(service.pricing_note)}

` : ""} + ${button("Ask About This Service", `/about-contact?service=${encodeURIComponent(service.name)}#contact-form`, "button small")} +
+
`; +} + +function priceText(item) { + if (item.contact_for_pricing) return "Contact for pricing"; + if (item.price_range) return escapeHtml(item.price_range); + if (item.starting_price) return `Starting at ${escapeHtml(item.starting_price)}`; + if (item.price) return escapeHtml(item.price); + return ""; +} + +function contactForm({ csrfToken, item = "", service = "", success = false, settings = {} }) { + return `
+
+

Contact

+

Contact Debbie

+

${escapeHtml(setting(settings, "business.preferred_contact", "Email or the website form are preferred."))}

+ ${success ? `

Thank you. Your message was saved and Debbie will be notified if email is configured.

` : ""} +
+
+ + + + + + + + + + + + +
+
`; +} + +function sectionHtml(section) { + if (!section.is_published) return ""; + const style = [ + section.background_color ? `background-color:${escapeHtml(section.background_color)}` : "", + section.background_url ? `background-image:url('${escapeHtml(section.background_url)}')` : "" + ].filter(Boolean).join(";"); + const image = section.image_url ? `${escapeHtml(section.image_alt || section.title || ` : ""; + const content = `
+ ${section.title ? `

${escapeHtml(section.title)}

` : ""} + ${cleanRichText(section.body || "")} + ${button(section.button_label, section.button_url)} +
`; + if (section.layout === "image-left") return `
${image}${content}
`; + if (section.layout === "image-right") return `
${content}${image}
`; + if (section.layout === "full-image") return `
${image}${content}
`; + if (section.layout === "contact-callout") return `
${content}
`; + return `
${content}
`; +} + +function mediaOption(media, selected) { + return ``; +} + +function mediaGrid(media) { + return `
${media.map((m) => ` +
+ ${escapeHtml(m.alt_text || m.title || + ${escapeHtml(m.title || m.original_name)} + ${escapeHtml(m.mime_type)} · ${bytes(m.size_bytes || 0)} ${m.width ? `· ${m.width}x${m.height}` : ""} +
+ `).join("")}
`; +} + +module.exports = { + publicLayout, + adminLayout, + authLayout, + setting, + button, + itemCard, + serviceCard, + priceText, + contactForm, + sectionHtml, + mediaOption, + mediaGrid +}; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..51665cb --- /dev/null +++ b/src/server.js @@ -0,0 +1,1093 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const express = require("express"); +const session = require("express-session"); +const bcrypt = require("bcryptjs"); +const multer = require("multer"); +const sharp = require("sharp"); +const nodemailer = require("nodemailer"); +const archiver = require("archiver"); +const config = require("./config"); +const { + db, + setSetting, + getSetting, + allSettings, + ensureCategory, + saveRevision, + audit +} = require("./database"); +const { + escapeHtml, + cleanRichText, + makeSlug, + token, + now, + bytes, + checkbox, + listFromText, + safeJson +} = require("./utils"); +const { + publicLayout, + adminLayout, + authLayout, + setting, + button, + itemCard, + serviceCard, + contactForm, + sectionHtml, + mediaOption, + mediaGrid +} = require("./render"); + +const app = express(); +app.set("trust proxy", 1); + +class SQLiteSessionStore extends session.Store { + get(sid, cb) { + try { + const row = db.prepare("SELECT sess FROM sessions WHERE sid = ? AND expired_at > ?").get(sid, Date.now()); + cb(null, row ? JSON.parse(row.sess) : null); + } catch (error) { + cb(error); + } + } + set(sid, sess, cb) { + try { + const maxAge = sess.cookie?.maxAge || 1000 * 60 * 60 * 8; + db.prepare(` + INSERT INTO sessions (sid, sess, expired_at) + VALUES (?, ?, ?) + ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expired_at = excluded.expired_at + `).run(sid, JSON.stringify(sess), Date.now() + maxAge); + cb(null); + } catch (error) { + cb(error); + } + } + destroy(sid, cb) { + try { + db.prepare("DELETE FROM sessions WHERE sid = ?").run(sid); + cb(null); + } catch (error) { + cb(error); + } + } +} + +app.use(express.urlencoded({ extended: true, limit: "1mb" })); +app.use(express.json({ limit: "1mb" })); +app.use(session({ + store: new SQLiteSessionStore(), + name: "dws.sid", + secret: config.sessionSecret, + resave: false, + saveUninitialized: false, + rolling: true, + cookie: { + httpOnly: true, + sameSite: "lax", + secure: config.production, + maxAge: 1000 * 60 * 60 * 8 + } +})); +app.use((req, res, next) => { + if (!req.session.csrfToken) req.session.csrfToken = token(16); + res.locals.csrfToken = req.session.csrfToken; + next(); +}); +app.use((req, res, next) => { + if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) { + const submitted = req.body?._csrf || req.headers["x-csrf-token"]; + if (!submitted || submitted !== req.session.csrfToken) return res.status(403).send("Security check failed. Please go back and try again."); + } + next(); +}); +app.use("/css", express.static(path.join(config.root, "public/css"), { maxAge: "7d" })); +app.use("/js", express.static(path.join(config.root, "public/js"), { maxAge: "7d" })); +app.use("/uploads", express.static(config.uploadDir, { maxAge: "30d", fallthrough: true })); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 8 * 1024 * 1024, files: 10 }, + fileFilter(req, file, cb) { + const ok = ["image/jpeg", "image/png", "image/webp"].includes(file.mimetype); + cb(ok ? null : new Error("Only JPG, PNG, and WebP images are allowed."), ok); + } +}); + +function adminCount() { + return db.prepare("SELECT COUNT(*) AS count FROM administrators").get().count; +} + +function currentAdmin(req) { + if (!req.session.adminId) return null; + return db.prepare("SELECT id, name, email, role FROM administrators WHERE id = ?").get(req.session.adminId); +} + +function requireAdmin(req, res, next) { + const admin = currentAdmin(req); + if (!admin) return res.redirect("/admin/login"); + req.admin = admin; + next(); +} + +function navLinks() { + return db.prepare("SELECT * FROM navigation WHERE is_published = 1 ORDER BY display_order, id").all(); +} + +function mediaRows() { + return db.prepare("SELECT * FROM media ORDER BY created_at DESC").all(); +} + +function pageBySlug(slug) { + return db.prepare("SELECT * FROM pages WHERE slug = ?").get(slug); +} + +function pageSections(pageId, includeHidden = false) { + return db.prepare(` + SELECT ps.*, m.url image_url, m.alt_text image_alt, bm.url background_url + FROM page_sections ps + LEFT JOIN media m ON m.id = ps.image_id + LEFT JOIN media bm ON bm.id = ps.background_image_id + WHERE ps.page_id = ? ${includeHidden ? "" : "AND ps.is_published = 1"} + ORDER BY ps.display_order, ps.id + `).all(pageId); +} + +function publicItems(where = "", params = []) { + return db.prepare(` + SELECT i.*, c.name category_name, m.url image_url, m.alt_text image_alt + FROM items i + LEFT JOIN categories c ON c.id = i.category_id + LEFT JOIN media m ON m.id = i.main_image_id + WHERE i.is_published = 1 AND i.is_archived = 0 ${where} + ORDER BY i.name COLLATE NOCASE + `).all(...params); +} + +function publicServices() { + return db.prepare(` + SELECT s.*, m.url image_url, m.alt_text image_alt + FROM services s + LEFT JOIN media m ON m.id = s.image_id + WHERE s.is_published = 1 + ORDER BY s.display_order, s.name + `).all(); +} + +function renderPublic(req, res, opts) { + const settings = allSettings(); + if (maintenanceActive(req, settings)) { + return res.status(503).send(publicLayout({ + title: "Website Maintenance", + description: "The website is temporarily in maintenance mode.", + settings, + nav: navLinks(), + csrfToken: res.locals.csrfToken, + currentPath: req.path, + body: maintenancePage(settings) + })); + } + res.send(publicLayout({ + ...opts, + settings, + nav: navLinks(), + csrfToken: res.locals.csrfToken, + currentPath: req.path + })); +} + +function renderAdmin(req, res, title, active, body) { + res.send(adminLayout({ title, active, body, admin: req.admin, csrfToken: res.locals.csrfToken })); +} + +function maintenanceActive(req, settings) { + return setting(settings, "maintenance.enabled", "false") === "true" && !req.session.adminId && !req.path.startsWith("/admin") && req.path !== "/healthz"; +} + +function maintenancePage(settings) { + const artwork = setting(settings, "maintenance.artwork", ""); + return `
+ ${artwork ? `` : ""} +

Website maintenance

+

${escapeHtml(setting(settings, "business.name", "Debbie Windler Seamstress"))}

+

${escapeHtml(setting(settings, "maintenance.message", "The website is getting a careful refresh. Please check back soon."))}

+ ${setting(settings, "maintenance.return_note", "") ? `

${escapeHtml(setting(settings, "maintenance.return_note"))}

` : ""} +
`; +} + +function ipHash(req) { + return crypto.createHash("sha256").update(`${req.ip}|${config.sessionSecret}`).digest("hex"); +} + +function rateLimit(key, limit, windowMs) { + const resetAt = Date.now() + windowMs; + const row = db.prepare("SELECT * FROM rate_limits WHERE key = ?").get(key); + if (!row || row.reset_at < Date.now()) { + db.prepare("INSERT INTO rate_limits (key, count, reset_at) VALUES (?, 1, ?) ON CONFLICT(key) DO UPDATE SET count = 1, reset_at = excluded.reset_at").run(key, resetAt); + return true; + } + if (row.count >= limit) return false; + db.prepare("UPDATE rate_limits SET count = count + 1 WHERE key = ?").run(key); + return true; +} + +async function storeImage(file, { title = "", alt = "", category = "" } = {}) { + if (!file) return null; + const metadata = await sharp(file.buffer, { failOn: "error" }).metadata(); + if (!["jpeg", "png", "webp"].includes(metadata.format)) throw new Error("That image format is not allowed."); + const base = `${Date.now()}-${token(4)}`; + const webName = `${base}.webp`; + const thumbName = `${base}-thumb.webp`; + const webPath = path.join(config.uploadDir, webName); + const thumbPath = path.join(config.uploadDir, thumbName); + await sharp(file.buffer).rotate().resize({ width: 1800, withoutEnlargement: true }).webp({ quality: 82 }).toFile(webPath); + await sharp(file.buffer).rotate().resize(420, 300, { fit: "cover" }).webp({ quality: 78 }).toFile(thumbPath); + const finalMeta = await sharp(webPath).metadata(); + const created = now(); + const result = db.prepare(` + INSERT INTO media (file_name, original_name, title, alt_text, category, mime_type, size_bytes, width, height, url, thumb_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'image/webp', ?, ?, ?, ?, ?, ?, ?) + `).run(webName, file.originalname, title || file.originalname, alt, category, fs.statSync(webPath).size, finalMeta.width, finalMeta.height, `/uploads/${webName}`, `/uploads/${thumbName}`, created, created); + return db.prepare("SELECT * FROM media WHERE id = ?").get(result.lastInsertRowid); +} + +async function notifyOwner(message) { + const ownerEmail = config.smtp.ownerEmail || getSetting("business.email", ""); + if (!config.smtp.host || !ownerEmail) return { status: "not_configured", error: "" }; + try { + const transporter = nodemailer.createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: config.smtp.secure, + auth: config.smtp.user ? { user: config.smtp.user, pass: config.smtp.pass } : undefined + }); + await transporter.sendMail({ + from: config.smtp.from, + to: ownerEmail, + replyTo: message.email, + subject: `Website inquiry: ${message.subject}`, + text: [ + `Name: ${message.name}`, + `Email: ${message.email}`, + `Phone: ${message.phone || ""}`, + `Preferred contact: ${message.preferred_contact || ""}`, + `Related: ${message.related || ""}`, + `Source: ${message.source_page || ""}`, + "", + message.message + ].join("\n") + }); + return { status: "sent", error: "" }; + } catch (error) { + return { status: "failed", error: error.message.slice(0, 500) }; + } +} + +app.get("/healthz", (req, res) => res.json({ ok: true, time: now() })); + +app.get("/", (req, res) => { + const settings = allSettings(); + const featured = publicItems("AND (i.featured = 1 OR i.newly_added = 1 OR i.on_sale = 1)").slice(0, 6); + const home = pageBySlug("home"); + const sections = pageSections(home.id); + const overlay = setting(settings, "home.hero.overlay", "light"); + const body = ` +
+
+

${escapeHtml(setting(settings, "business.tagline", ""))}

+

${escapeHtml(setting(settings, "home.hero.title", "Debbie Windler Seamstress"))}

+

${escapeHtml(setting(settings, "home.hero.subtitle", ""))}

+

${escapeHtml(setting(settings, "home.hero.contact", ""))}

+
+ ${button(setting(settings, "home.hero.button1_label"), setting(settings, "home.hero.button1_url"))} + ${button(setting(settings, "home.hero.button2_label"), setting(settings, "home.hero.button2_url"), "button secondary")} +
+
+
+
+
+

Showcase

+

${escapeHtml(setting(settings, "home.highlight.title", "Featured and Newly Added"))}

+

${escapeHtml(setting(settings, "home.highlight.intro", ""))}

+
+
${featured.map(itemCard).join("")}
+
+ ${sections.map(sectionHtml).join("")} + `; + res.send(publicLayout({ + title: setting(settings, "site.title", "Debbie Windler Seamstress"), + description: setting(settings, "site.description", ""), + settings, + nav: navLinks(), + csrfToken: res.locals.csrfToken, + currentPath: req.path, + body + })); +}); + +app.get("/items", (req, res) => { + const settings = allSettings(); + const q = String(req.query.q || "").trim(); + const category = String(req.query.category || "").trim(); + const availability = String(req.query.availability || "").trim(); + const where = []; + const params = []; + if (q) { + where.push("AND (i.name LIKE ? OR i.short_description LIKE ? OR i.full_description LIKE ?)"); + params.push(`%${q}%`, `%${q}%`, `%${q}%`); + } + if (category) { + where.push("AND c.slug = ?"); + params.push(category); + } + if (availability) { + where.push("AND i.availability_status = ?"); + params.push(availability); + } + const items = publicItems(where.join(" "), params); + const categories = db.prepare("SELECT * FROM categories ORDER BY name").all(); + const statuses = db.prepare("SELECT DISTINCT availability_status FROM items WHERE is_published = 1 ORDER BY availability_status").all(); + const alphabet = [...new Set(items.map((item) => item.name[0]?.toUpperCase()).filter(Boolean))]; + const body = ` +

Items Available and Custom Orders

Browse sample listings alphabetically. Every item can be edited, hidden, marked new, put on sale, or offered as a custom order.

+
+
+ + + + +
+ ${alphabet.length > 6 ? `
${alphabet.map((letter) => `${letter}`).join("")}
` : ""} +
+
${items.map(itemCard).join("") || "

No items match that search yet.

"}
+ `; + renderPublic(req, res, { title: "Items Available and Custom Orders", description: "Items, custom orders, and sewing examples.", body }); +}); + +app.get("/items/:slug", (req, res) => { + const item = db.prepare(` + SELECT i.*, c.name category_name, m.url image_url, m.alt_text image_alt + FROM items i + LEFT JOIN categories c ON c.id = i.category_id + LEFT JOIN media m ON m.id = i.main_image_id + WHERE i.slug = ? AND i.is_published = 1 AND i.is_archived = 0 + `).get(req.params.slug); + if (!item) return res.status(404).send("Item not found"); + const gallery = db.prepare(` + SELECT m.* FROM item_images ii JOIN media m ON m.id = ii.media_id + WHERE ii.item_id = ? ORDER BY ii.display_order, ii.id + `).all(item.id); + const body = `
+
${item.image_url ? `${escapeHtml(item.image_alt || item.name)}` : `
`}
+
+

${escapeHtml(item.category_name || "Item")}

+

${escapeHtml(item.name)}

+

${escapeHtml(item.availability_status)}

+

${item.contact_for_pricing ? "Contact for pricing" : escapeHtml(item.price || item.starting_price || item.price_range || "")}

+ ${cleanRichText(item.full_description || item.short_description || "")} + ${item.material_notes ? `

Materials

${escapeHtml(item.material_notes)}

` : ""} + ${item.color_size_notes ? `

Color or Size Notes

${escapeHtml(item.color_size_notes)}

` : ""} + ${item.care_instructions ? `

Care Instructions

${escapeHtml(item.care_instructions)}

` : ""} + ${button("Ask About This Item", `/about-contact?item=${encodeURIComponent(item.name)}#contact-form`)} +
+
+ ${gallery.length ? `` : ""}`; + renderPublic(req, res, { title: item.name, description: item.short_description, body }); +}); + +app.get("/services", (req, res) => { + const page = pageBySlug("services"); + const services = publicServices(); + const sections = pageSections(page.id); + const body = `

${escapeHtml(page.title)}

${escapeHtml(page.intro || "")}

+
${services.map(serviceCard).join("")}
+ ${sections.map(sectionHtml).join("")}`; + renderPublic(req, res, { title: page.title, description: page.meta_description, body }); +}); + +app.get("/about-contact", (req, res) => { + const settings = allSettings(); + const page = pageBySlug("about-contact"); + const sections = pageSections(page.id); + const equipment = db.prepare(` + SELECT e.*, m.url image_url, m.alt_text image_alt + FROM equipment e LEFT JOIN media m ON m.id = e.image_id + WHERE e.is_published = 1 ORDER BY e.display_order, e.name + `).all(); + const body = `

${escapeHtml(page.title)}

${escapeHtml(page.intro || "")}

+ ${sections.map(sectionHtml).join("")} +
+

Equipment

Sewing Room and Equipment

+
${equipment.map((e) => `
${e.image_url ? `${escapeHtml(e.image_alt || e.name)}` : `
`}

${escapeHtml(e.name)}

${escapeHtml([e.brand, e.model].filter(Boolean).join(" "))}

${escapeHtml(e.description || "")}

`).join("")}
+
+
+

Contact Information

+
+
Email
${escapeHtml(setting(settings, "business.email", "To be supplied"))}
+
Phone
${escapeHtml(setting(settings, "business.phone", "To be supplied"))}
+
Service area
${escapeHtml(setting(settings, "business.service_area", "To be supplied"))}
+
Hours
${escapeHtml(setting(settings, "business.hours", "By appointment"))}
+
+
+ ${contactForm({ csrfToken: res.locals.csrfToken, item: req.query.item, service: req.query.service, success: req.query.sent === "1", settings })}`; + renderPublic(req, res, { title: page.title, description: page.meta_description, body }); +}); + +app.post("/contact", async (req, res) => { + if (!rateLimit(`contact:${ipHash(req)}`, 5, 60 * 60 * 1000)) return res.status(429).send("Too many messages were sent recently. Please try again later."); + if (req.body.website) return res.redirect("/about-contact?sent=1#contact-form"); + const fields = { + name: String(req.body.name || "").trim().slice(0, 120), + email: String(req.body.email || "").trim().slice(0, 180), + phone: String(req.body.phone || "").trim().slice(0, 80), + preferred_contact: String(req.body.preferred_contact || "").trim().slice(0, 80), + subject: String(req.body.subject || "").trim().slice(0, 160), + message: String(req.body.message || "").trim().slice(0, 3000), + related: String(req.body.related || "").trim().slice(0, 160), + source_page: String(req.body.source_page || req.get("referer") || "").slice(0, 300) + }; + if (!fields.name || !fields.email || !fields.subject || !fields.message || !req.body.consent || !fields.email.includes("@")) { + return res.status(400).send("Please complete the required contact form fields and try again."); + } + const emailResult = await notifyOwner(fields); + const created = now(); + db.prepare(` + INSERT INTO messages + (name, email, phone, preferred_contact, subject, message, related_item, consent, source_page, ip_hash, user_agent, email_notification_status, email_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?) + `).run(fields.name, fields.email, fields.phone, fields.preferred_contact, fields.subject, fields.message, fields.related, fields.source_page, ipHash(req), String(req.get("user-agent") || "").slice(0, 300), emailResult.status, emailResult.error, created, created); + res.redirect("/about-contact?sent=1#contact-form"); +}); + +app.get("/robots.txt", (req, res) => { + const settings = allSettings(); + const visible = setting(settings, "seo.visible", "true") !== "false"; + res.type("text/plain").send(`${visible ? "User-agent: *\nAllow: /" : "User-agent: *\nDisallow: /"}\nSitemap: ${config.baseUrl}/sitemap.xml\n`); +}); + +app.get("/sitemap.xml", (req, res) => { + const items = publicItems(); + const urls = ["/", "/items", "/services", "/about-contact", ...items.map((item) => `/items/${item.slug}`)]; + res.type("application/xml").send(`${urls.map((url) => `${escapeHtml(config.baseUrl + url)}`).join("")}`); +}); + +app.get("/admin", (req, res) => { + if (!adminCount()) return res.redirect("/admin/setup"); + if (!currentAdmin(req)) return res.redirect("/admin/login"); + res.redirect("/admin/dashboard"); +}); + +app.get("/admin/setup", (req, res) => { + if (adminCount()) return res.redirect("/admin/login"); + res.send(authLayout("Create Owner Account", `

Create the first owner login. No password is stored as plain text.

+
+ + + + + +
`)); +}); + +app.post("/admin/setup", async (req, res) => { + if (adminCount()) return res.redirect("/admin/login"); + const password = String(req.body.password || ""); + if (password.length < 10) return res.status(400).send("Please use a password with at least 10 characters."); + const hash = await bcrypt.hash(password, 12); + const ts = now(); + const result = db.prepare("INSERT INTO administrators (name, email, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(String(req.body.name || "Owner").trim(), String(req.body.email || "").trim().toLowerCase(), hash, ts, ts); + req.session.adminId = result.lastInsertRowid; + audit(result.lastInsertRowid, "Created owner account", "", req.ip); + res.redirect("/admin/dashboard"); +}); + +app.get("/admin/login", (req, res) => { + if (!adminCount()) return res.redirect("/admin/setup"); + res.send(authLayout("Owner Login", `
+ + + + +
`)); +}); + +app.post("/admin/login", async (req, res) => { + if (!rateLimit(`login:${ipHash(req)}`, 8, 15 * 60 * 1000)) return res.status(429).send("Too many login tries. Please wait and try again."); + const admin = db.prepare("SELECT * FROM administrators WHERE email = ?").get(String(req.body.email || "").trim().toLowerCase()); + if (!admin || !(await bcrypt.compare(String(req.body.password || ""), admin.password_hash))) return res.status(401).send("Login failed. Check the email and password."); + req.session.regenerate((error) => { + if (error) return res.status(500).send("Login could not be completed."); + req.session.adminId = admin.id; + req.session.csrfToken = token(16); + db.prepare("UPDATE administrators SET last_login_at = ? WHERE id = ?").run(now(), admin.id); + audit(admin.id, "Logged in", "", req.ip); + res.redirect("/admin/dashboard"); + }); +}); + +app.post("/admin/logout", requireAdmin, (req, res) => { + req.session.destroy(() => res.redirect("/admin/login")); +}); + +app.get("/admin/dashboard", requireAdmin, (req, res) => { + const stats = { + unread: db.prepare("SELECT COUNT(*) count FROM messages WHERE status = 'unread'").get().count, + publishedItems: db.prepare("SELECT COUNT(*) count FROM items WHERE is_published = 1 AND is_archived = 0").get().count, + hiddenItems: db.prepare("SELECT COUNT(*) count FROM items WHERE is_published = 0 OR is_archived = 1").get().count, + newItems: db.prepare("SELECT COUNT(*) count FROM items WHERE newly_added = 1").get().count, + featuredItems: db.prepare("SELECT COUNT(*) count FROM items WHERE featured = 1").get().count, + services: db.prepare("SELECT COUNT(*) count FROM services WHERE is_published = 1").get().count, + media: db.prepare("SELECT COALESCE(SUM(size_bytes),0) bytes FROM media").get().bytes, + backups: db.prepare("SELECT * FROM backups ORDER BY created_at DESC LIMIT 1").get() + }; + const messages = db.prepare("SELECT * FROM messages ORDER BY created_at DESC LIMIT 5").all(); + const changes = db.prepare("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 8").all(); + const body = `
+ ${Object.entries({ + "Unread messages": stats.unread, + "Published items": stats.publishedItems, + "Hidden or draft items": stats.hiddenItems, + "New items": stats.newItems, + "Featured items": stats.featuredItems, + "Published services": stats.services, + "Storage used": bytes(stats.media), + "Last backup": stats.backups ? stats.backups.created_at.slice(0, 10) : "No backup yet" + }).map(([label, value]) => `
${escapeHtml(value)}${label}
`).join("")} +
+
+ ${button("Add Item", "/admin/items/new")} + ${button("Add Service", "/admin/services/new")} + ${button("Upload Image", "/admin/media")} + ${button("Edit Home Page", "/admin/home")} + ${button("Read Messages", "/admin/messages")} + ${button("Edit Contact Information", "/admin/business")} +
+
+

Recent Messages

${messages.map((m) => `

${escapeHtml(m.subject)}
${escapeHtml(m.name)} · ${escapeHtml(m.created_at)}

`).join("") || "

No messages yet.

"}
+

Recent Website Changes

${changes.map((c) => `

${escapeHtml(c.action)}
${escapeHtml(c.created_at)}

`).join("") || "

No changes yet.

"}
+
`; + renderAdmin(req, res, "Dashboard", "dashboard", body); +}); + +app.get("/admin/home", requireAdmin, (req, res) => { + const s = allSettings(); + const body = `
+ ${csrf(res)} +

Hero Area

+ ${input("Business name in hero", "home.hero.title", setting(s, "home.hero.title"))} + ${textarea("Subtitle", "home.hero.subtitle", setting(s, "home.hero.subtitle"))} + ${input("Contact line", "home.hero.contact", setting(s, "home.hero.contact"))} + ${input("Hero background URL", "home.hero.image", setting(s, "home.hero.image"))} + ${select("Image position", "home.hero.position", ["center", "top", "bottom", "left", "right"], setting(s, "home.hero.position"))} + ${select("Image sizing", "home.hero.size", ["cover", "contain", "auto"], setting(s, "home.hero.size"))} + ${select("Overlay style", "home.hero.overlay", ["light", "dark", "none"], setting(s, "home.hero.overlay"))} + ${input("Overlay opacity", "home.hero.overlay_opacity", setting(s, "home.hero.overlay_opacity"), "number", "0", "1", "0.05")} + ${input("Hero text color", "home.hero.text_color", setting(s, "home.hero.text_color"), "color")} + ${input("First button label", "home.hero.button1_label", setting(s, "home.hero.button1_label"))} + ${input("First button link", "home.hero.button1_url", setting(s, "home.hero.button1_url"))} + ${input("Second button label", "home.hero.button2_label", setting(s, "home.hero.button2_label"))} + ${input("Second button link", "home.hero.button2_url", setting(s, "home.hero.button2_url"))} +
+

Featured and Newly Added Area

+ ${input("Section title", "home.highlight.title", setting(s, "home.highlight.title"))} + ${textarea("Intro text", "home.highlight.intro", setting(s, "home.highlight.intro"))} + ${input("Background color", "home.highlight.background_color", setting(s, "home.highlight.background_color"), "color")} +
+ + Preview Home Page +
`; + renderAdmin(req, res, "Home Page Editor", "home", body); +}); + +app.post("/admin/home", requireAdmin, (req, res) => { + Object.keys(req.body).filter((key) => key.startsWith("home.")).forEach((key) => setSetting(key, req.body[key])); + audit(req.admin.id, "Updated home page settings", "", req.ip); + res.redirect("/admin/home?saved=1"); +}); + +app.get("/admin/pages", requireAdmin, (req, res) => { + const pages = db.prepare("SELECT * FROM pages ORDER BY id").all(); + renderAdmin(req, res, "Page Editor", "pages", `
${pages.map((p) => ``).join("")}
`); +}); + +app.get("/admin/pages/:slug", requireAdmin, (req, res) => { + const page = pageBySlug(req.params.slug); + if (!page) return res.status(404).send("Page not found"); + const sections = pageSections(page.id, true); + const media = mediaRows(); + const body = `
+ ${csrf(res)} +
+ ${input("Page title", "title", page.title)} + ${input("Page subtitle", "subtitle", page.subtitle || "")} + ${textarea("Introduction", "intro", page.intro || "")} + ${input("Search title", "meta_title", page.meta_title || "")} + ${textarea("Search description", "meta_description", page.meta_description || "")} + ${select("Search visibility", "search_visible", [["1","Visible to search engines"], ["0","Hidden from search engines"]], String(page.search_visible))} + ${select("Status", "status", ["published", "draft"], page.status)} +
+ + Preview Page +
+

Content Sections

+ ${sections.map((section) => sectionForm(section, media, res.locals.csrfToken, `/admin/pages/${page.slug}/sections/${section.id}`)).join("")} +
+ ${csrf(res)} +
+
`; + renderAdmin(req, res, `Edit ${page.title}`, "pages", body); +}); + +app.post("/admin/pages/:slug", requireAdmin, (req, res) => { + const page = pageBySlug(req.params.slug); + saveRevision("page", page.id, page.title, page, req.admin.id); + db.prepare("UPDATE pages SET title=?, subtitle=?, intro=?, meta_title=?, meta_description=?, search_visible=?, status=?, updated_at=? WHERE id=?") + .run(req.body.title, req.body.subtitle, req.body.intro, req.body.meta_title, req.body.meta_description, checkbox(req.body.search_visible === "1"), req.body.status, now(), page.id); + audit(req.admin.id, `Updated page ${page.title}`, "", req.ip); + res.redirect(`/admin/pages/${page.slug}`); +}); + +app.post("/admin/pages/:slug/sections/new", requireAdmin, (req, res) => { + const page = pageBySlug(req.params.slug); + db.prepare("INSERT INTO page_sections (page_id, title, body, layout, display_order, is_published, updated_at) VALUES (?, 'New Section', '

Edit this section.

', 'text', (SELECT COALESCE(MAX(display_order),0)+10 FROM page_sections WHERE page_id=?), 0, ?)") + .run(page.id, page.id, now()); + res.redirect(`/admin/pages/${page.slug}`); +}); + +app.post("/admin/pages/:slug/sections/:id", requireAdmin, (req, res) => { + const page = pageBySlug(req.params.slug); + const section = db.prepare("SELECT * FROM page_sections WHERE id = ?").get(req.params.id); + saveRevision("section", section.id, section.title, section, req.admin.id); + db.prepare("UPDATE page_sections SET title=?, body=?, layout=?, image_id=?, background_image_id=?, background_color=?, button_label=?, button_url=?, display_order=?, is_published=?, updated_at=? WHERE id=?") + .run(req.body.title, cleanRichText(req.body.body), req.body.layout, req.body.image_id || null, req.body.background_image_id || null, req.body.background_color, req.body.button_label, req.body.button_url, Number(req.body.display_order || 0), checkbox(req.body.is_published), now(), section.id); + audit(req.admin.id, `Updated section on ${page.title}`, "", req.ip); + res.redirect(`/admin/pages/${page.slug}`); +}); + +function csrf(res) { + return ``; +} + +function input(label, name, value = "", type = "text", min = "", max = "", step = "") { + return ``; +} + +function textarea(label, name, value = "") { + return ``; +} + +function select(label, name, options, selected = "") { + const html = options.map((option) => { + const value = Array.isArray(option) ? option[0] : option; + const text = Array.isArray(option) ? option[1] : option; + return ``; + }).join(""); + return ``; +} + +function checkboxInput(label, name, checked) { + return ``; +} + +function sectionForm(section, media, csrfToken, action) { + return `
${escapeHtml(section.title || "Untitled section")} ${section.is_published ? "" : "(hidden)"} +
+ + ${input("Heading", "title", section.title || "")} + ${textarea("Text", "body", section.body || "")} + ${select("Layout", "layout", [["text", "Text only"], ["image-left", "Image left, text right"], ["image-right", "Text left, image right"], ["full-image", "Full-width image"], ["contact-callout", "Contact callout"]], section.layout)} + + + ${input("Background color", "background_color", section.background_color || "", "color")} + ${input("Button label", "button_label", section.button_label || "")} + ${input("Button link", "button_url", section.button_url || "")} + ${input("Display order", "display_order", section.display_order || 0, "number")} + ${checkboxInput("Show this section on the public page", "is_published", section.is_published)} + +
+
`; +} + +app.get("/admin/items", requireAdmin, (req, res) => { + const q = String(req.query.q || ""); + const items = db.prepare(` + SELECT i.*, c.name category_name FROM items i LEFT JOIN categories c ON c.id = i.category_id + WHERE i.name LIKE ? OR i.short_description LIKE ? + ORDER BY i.name + `).all(`%${q}%`, `%${q}%`); + const body = `
Add Item
+
${items.map((i) => `

${escapeHtml(i.name)}

${escapeHtml(i.category_name || "")} · ${escapeHtml(i.availability_status)} ${i.is_published ? "" : "· Hidden"}

EditPreview
`).join("")}
`; + renderAdmin(req, res, "Items Manager", "items", body); +}); + +app.get("/admin/items/new", requireAdmin, (req, res) => renderAdmin(req, res, "Add Item", "items", itemEditor(null, res.locals.csrfToken))); +app.get("/admin/items/:id/edit", requireAdmin, (req, res) => { + const item = db.prepare("SELECT i.*, c.name category_name FROM items i LEFT JOIN categories c ON c.id = i.category_id WHERE i.id = ?").get(req.params.id); + if (!item) return res.status(404).send("Item not found"); + renderAdmin(req, res, `Edit ${item.name}`, "items", itemEditor(item, res.locals.csrfToken)); +}); + +app.post("/admin/items/new", requireAdmin, upload.array("images", 8), async (req, res, next) => { + try { + const id = await saveItem(req, null); + res.redirect(`/admin/items/${id}/edit`); + } catch (error) { next(error); } +}); +app.post("/admin/items/:id/edit", requireAdmin, upload.array("images", 8), async (req, res, next) => { + try { + await saveItem(req, req.params.id); + res.redirect(`/admin/items/${req.params.id}/edit`); + } catch (error) { next(error); } +}); +app.post("/admin/items/:id/duplicate", requireAdmin, (req, res) => { + const item = db.prepare("SELECT * FROM items WHERE id=?").get(req.params.id); + if (!item) return res.status(404).send("Item not found"); + const copyName = `${item.name} Copy`; + const copySlug = `${item.slug}-copy-${token(3)}`; + const ts = now(); + const result = db.prepare(`INSERT INTO items (name, slug, short_description, full_description, category_id, main_image_id, price, starting_price, price_range, contact_for_pricing, quantity, availability_status, custom_order_available, color_size_notes, material_notes, care_instructions, featured, newly_added, on_sale, sale_text, display_order, is_published, is_archived, date_added, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 0, 0, ?, NULL, ?)`) + .run(copyName, copySlug, item.short_description, item.full_description, item.category_id, item.main_image_id, item.price, item.starting_price, item.price_range, item.contact_for_pricing, item.quantity, item.availability_status, item.custom_order_available, item.color_size_notes, item.material_notes, item.care_instructions, item.sale_text, item.display_order + 1, ts, ts); + audit(req.admin.id, "Duplicated item", item.name, req.ip); + res.redirect(`/admin/items/${result.lastInsertRowid}/edit`); +}); +app.post("/admin/items/:id/archive", requireAdmin, (req, res) => { + db.prepare("UPDATE items SET is_archived=1, is_published=0, updated_at=? WHERE id=?").run(now(), req.params.id); + audit(req.admin.id, "Archived item", String(req.params.id), req.ip); + res.redirect("/admin/items"); +}); +app.post("/admin/items/:id/delete", requireAdmin, (req, res) => { + db.prepare("DELETE FROM items WHERE id=?").run(req.params.id); + audit(req.admin.id, "Deleted item", String(req.params.id), req.ip); + res.redirect("/admin/items"); +}); + +async function saveItem(req, id) { + const categoryId = req.body.category ? ensureCategory(req.body.category.trim()) : null; + const ts = now(); + const slug = makeSlug(req.body.slug || req.body.name, "item"); + if (id) { + const old = db.prepare("SELECT * FROM items WHERE id=?").get(id); + saveRevision("item", id, old.name, old, req.admin.id); + db.prepare(`UPDATE items SET name=?, slug=?, short_description=?, full_description=?, category_id=?, price=?, starting_price=?, price_range=?, contact_for_pricing=?, quantity=?, availability_status=?, custom_order_available=?, color_size_notes=?, material_notes=?, care_instructions=?, featured=?, newly_added=?, on_sale=?, sale_text=?, display_order=?, is_published=?, updated_at=? WHERE id=?`) + .run(req.body.name, slug, req.body.short_description, cleanRichText(req.body.full_description), categoryId, req.body.price, req.body.starting_price, req.body.price_range, checkbox(req.body.contact_for_pricing), req.body.quantity, req.body.availability_status, checkbox(req.body.custom_order_available), req.body.color_size_notes, req.body.material_notes, req.body.care_instructions, checkbox(req.body.featured), checkbox(req.body.newly_added), checkbox(req.body.on_sale), req.body.sale_text, Number(req.body.display_order || 0), checkbox(req.body.is_published), ts, id); + } else { + const result = db.prepare(`INSERT INTO items (name, slug, short_description, full_description, category_id, price, starting_price, price_range, contact_for_pricing, quantity, availability_status, custom_order_available, color_size_notes, material_notes, care_instructions, featured, newly_added, on_sale, sale_text, display_order, is_published, date_added, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(req.body.name, slug, req.body.short_description, cleanRichText(req.body.full_description), categoryId, req.body.price, req.body.starting_price, req.body.price_range, checkbox(req.body.contact_for_pricing), req.body.quantity, req.body.availability_status, checkbox(req.body.custom_order_available), req.body.color_size_notes, req.body.material_notes, req.body.care_instructions, checkbox(req.body.featured), checkbox(req.body.newly_added), checkbox(req.body.on_sale), req.body.sale_text, Number(req.body.display_order || 0), checkbox(req.body.is_published), ts, ts, ts); + id = result.lastInsertRowid; + } + for (const file of req.files || []) { + const media = await storeImage(file, { title: req.body.name, alt: req.body.name, category: "Items" }); + db.prepare("INSERT INTO item_images (item_id, media_id, display_order, is_main) VALUES (?, ?, (SELECT COALESCE(MAX(display_order),0)+10 FROM item_images WHERE item_id=?), 0)").run(id, media.id, id); + const item = db.prepare("SELECT main_image_id FROM items WHERE id=?").get(id); + if (!item.main_image_id) db.prepare("UPDATE items SET main_image_id=? WHERE id=?").run(media.id, id); + } + audit(req.admin.id, id ? "Saved item" : "Added item", req.body.name, req.ip); + return id; +} + +function itemEditor(item, csrfToken) { + const values = item || { availability_status: "Available", is_published: 1 }; + return `
+ + ${input("Item name", "name", values.name || "")} + ${input("Clean page address", "slug", values.slug || "")} + ${textarea("Short description", "short_description", values.short_description || "")} + ${textarea("Full description", "full_description", values.full_description || "")} + ${input("Category", "category", values.category_name || "")} + ${input("Price", "price", values.price || "")} + ${input("Starting price", "starting_price", values.starting_price || "")} + ${input("Price range", "price_range", values.price_range || "")} + ${checkboxInput("Contact for pricing", "contact_for_pricing", values.contact_for_pricing)} + ${input("Available quantity", "quantity", values.quantity || "")} + ${select("Availability", "availability_status", ["Available", "Made to Order", "Custom Order", "Sold", "Seasonal", "On Sale", "Hidden"], values.availability_status)} + ${checkboxInput("Custom order available", "custom_order_available", values.custom_order_available)} + ${textarea("Color or size notes", "color_size_notes", values.color_size_notes || "")} + ${textarea("Material notes", "material_notes", values.material_notes || "")} + ${textarea("Care instructions", "care_instructions", values.care_instructions || "")} + ${checkboxInput("Feature this item", "featured", values.featured)} + ${checkboxInput("Mark as newly added", "newly_added", values.newly_added)} + ${checkboxInput("Mark as on sale", "on_sale", values.on_sale)} + ${input("Sale text", "sale_text", values.sale_text || "")} + ${input("Display order", "display_order", values.display_order || 0, "number")} + ${checkboxInput("Publish this item", "is_published", values.is_published)} + + +
+ ${values.id ? `
+
+
+
+
` : ""}`; +} + +app.get("/admin/services", requireAdmin, (req, res) => { + const services = db.prepare("SELECT * FROM services ORDER BY display_order, name").all(); + renderAdmin(req, res, "Services Manager", "services", `

Add Service

${services.map((s) => `

${escapeHtml(s.name)}

${s.is_published ? "Published" : "Hidden"} · Order ${s.display_order}

Edit
`).join("")}
`); +}); + +app.get("/admin/services/new", requireAdmin, (req, res) => renderAdmin(req, res, "Add Service", "services", serviceEditor(null, res.locals.csrfToken))); +app.get("/admin/services/:id/edit", requireAdmin, (req, res) => { + const service = db.prepare("SELECT * FROM services WHERE id=?").get(req.params.id); + renderAdmin(req, res, `Edit ${service.name}`, "services", serviceEditor(service, res.locals.csrfToken)); +}); +app.post("/admin/services/new", requireAdmin, upload.single("image"), async (req, res, next) => { + try { const id = await saveService(req, null); res.redirect(`/admin/services/${id}/edit`); } catch (e) { next(e); } +}); +app.post("/admin/services/:id/edit", requireAdmin, upload.single("image"), async (req, res, next) => { + try { await saveService(req, req.params.id); res.redirect(`/admin/services/${req.params.id}/edit`); } catch (e) { next(e); } +}); +app.post("/admin/services/:id/duplicate", requireAdmin, (req, res) => { + const s = db.prepare("SELECT * FROM services WHERE id=?").get(req.params.id); + if (!s) return res.status(404).send("Service not found"); + const result = db.prepare("INSERT INTO services (name, slug, image_id, short_description, full_description, pricing_note, display_order, featured, is_published, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?)") + .run(`${s.name} Copy`, `${s.slug}-copy-${token(3)}`, s.image_id, s.short_description, s.full_description, s.pricing_note, s.display_order + 1, now()); + audit(req.admin.id, "Duplicated service", s.name, req.ip); + res.redirect(`/admin/services/${result.lastInsertRowid}/edit`); +}); +app.post("/admin/services/:id/delete", requireAdmin, (req, res) => { + db.prepare("DELETE FROM services WHERE id=?").run(req.params.id); + audit(req.admin.id, "Deleted service", String(req.params.id), req.ip); + res.redirect("/admin/services"); +}); + +async function saveService(req, id) { + const media = req.file ? await storeImage(req.file, { title: req.body.name, alt: req.body.name, category: "Services" }) : null; + const ts = now(); + const slug = makeSlug(req.body.slug || req.body.name, "service"); + if (id) { + const old = db.prepare("SELECT * FROM services WHERE id=?").get(id); + saveRevision("service", id, old.name, old, req.admin.id); + db.prepare("UPDATE services SET name=?, slug=?, image_id=COALESCE(?, image_id), short_description=?, full_description=?, pricing_note=?, display_order=?, featured=?, is_published=?, updated_at=? WHERE id=?") + .run(req.body.name, slug, media?.id || null, req.body.short_description, cleanRichText(req.body.full_description), req.body.pricing_note, Number(req.body.display_order || 0), checkbox(req.body.featured), checkbox(req.body.is_published), ts, id); + } else { + const result = db.prepare("INSERT INTO services (name, slug, image_id, short_description, full_description, pricing_note, display_order, featured, is_published, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + .run(req.body.name, slug, media?.id || null, req.body.short_description, cleanRichText(req.body.full_description), req.body.pricing_note, Number(req.body.display_order || 0), checkbox(req.body.featured), checkbox(req.body.is_published), ts, ts); + id = result.lastInsertRowid; + } + audit(req.admin.id, "Saved service", req.body.name, req.ip); + return id; +} + +function serviceEditor(service, csrfToken) { + const s = service || { is_published: 1, display_order: 0 }; + return `
+ + ${input("Service name", "name", s.name || "")} + ${input("Clean page address", "slug", s.slug || "")} + ${textarea("Short description", "short_description", s.short_description || "")} + ${textarea("Full description", "full_description", s.full_description || "")} + ${input("Pricing note", "pricing_note", s.pricing_note || "")} + ${input("Display order", "display_order", s.display_order || 0, "number")} + ${checkboxInput("Feature this service", "featured", s.featured)} + ${checkboxInput("Publish this service", "is_published", s.is_published)} + + +
+ ${s.id ? `
+
+
+
` : ""}`; +} + +app.get("/admin/about-equipment", requireAdmin, (req, res) => { + const equipment = db.prepare("SELECT * FROM equipment ORDER BY display_order, name").all(); + const body = `

Edit About Page Text

+

Equipment

${equipment.map((e) => equipmentForm(e, res.locals.csrfToken)).join("")} +
${csrf(res)}
`; + renderAdmin(req, res, "About and Equipment Manager", "about", body); +}); + +app.post("/admin/about-equipment/new", requireAdmin, (req, res) => { + db.prepare("INSERT INTO equipment (name, type, description, display_order, is_published, updated_at) VALUES ('New Equipment', '', '', 100, 0, ?)").run(now()); + res.redirect("/admin/about-equipment"); +}); +app.post("/admin/about-equipment/:id", requireAdmin, (req, res) => { + db.prepare("UPDATE equipment SET name=?, type=?, brand=?, model=?, description=?, display_order=?, is_published=?, updated_at=? WHERE id=?") + .run(req.body.name, req.body.type, req.body.brand, req.body.model, req.body.description, Number(req.body.display_order || 0), checkbox(req.body.is_published), now(), req.params.id); + audit(req.admin.id, "Saved equipment", req.body.name, req.ip); + res.redirect("/admin/about-equipment"); +}); +function equipmentForm(e, csrfToken) { + return `
${escapeHtml(e.name)} ${e.is_published ? "" : "(hidden)"}
+ ${input("Equipment name", "name", e.name)}${input("Type", "type", e.type || "")}${input("Brand", "brand", e.brand || "")}${input("Model", "model", e.model || "")}${textarea("Description", "description", e.description || "")}${input("Display order", "display_order", e.display_order, "number")}${checkboxInput("Publish this equipment", "is_published", e.is_published)}
`; +} + +app.get("/admin/messages", requireAdmin, (req, res) => { + const status = req.query.status || ""; + const rows = db.prepare(`SELECT * FROM messages ${status ? "WHERE status=?" : ""} ORDER BY created_at DESC`).all(...(status ? [status] : [])); + const body = ` +
${rows.map((m) => `

${escapeHtml(m.subject)}

${escapeHtml(m.name)} · ${escapeHtml(m.email)} · ${escapeHtml(m.created_at)} · ${escapeHtml(m.status)}

`).join("") || "

No messages here.

"}
`; + renderAdmin(req, res, "Messages Inbox", "messages", body); +}); +app.get("/admin/messages/export", requireAdmin, (req, res) => { + const rows = db.prepare("SELECT created_at,name,email,phone,preferred_contact,subject,message,related_item,source_page,status,email_notification_status FROM messages ORDER BY created_at DESC").all(); + const csv = ["created_at,name,email,phone,preferred_contact,subject,message,related_item,source_page,status,email_notification_status", ...rows.map((r) => Object.values(r).map((v) => `"${String(v ?? "").replaceAll('"', '""')}"`).join(","))].join("\n"); + res.type("text/csv").attachment("debbie-windler-messages.csv").send(csv); +}); +app.get("/admin/messages/:id", requireAdmin, (req, res) => { + const m = db.prepare("SELECT * FROM messages WHERE id=?").get(req.params.id); + if (!m) return res.status(404).send("Message not found"); + db.prepare("UPDATE messages SET status='read', updated_at=? WHERE id=? AND status='unread'").run(now(), m.id); + const mailto = `mailto:${encodeURIComponent(m.email)}?subject=${encodeURIComponent(`Re: ${m.subject}`)}`; + renderAdmin(req, res, m.subject, "messages", `

From: ${escapeHtml(m.name)} · ${escapeHtml(m.email)} · ${escapeHtml(m.phone || "")}

Preferred: ${escapeHtml(m.preferred_contact || "")}

Related: ${escapeHtml(m.related_item || "")}

Source: ${escapeHtml(m.source_page || "")}

Email notice: ${escapeHtml(m.email_notification_status)} ${m.email_error ? `- ${escapeHtml(m.email_error)}` : ""}

${escapeHtml(m.message)}
${csrf(res)}Reply by Email
`); +}); +app.post("/admin/messages/:id/status", requireAdmin, (req, res) => { + db.prepare("UPDATE messages SET status=?, updated_at=? WHERE id=?").run(req.body.status, now(), req.params.id); + res.redirect("/admin/messages"); +}); + +app.get("/admin/media", requireAdmin, (req, res) => { + const media = mediaRows(); + const body = `
+ ${csrf(res)}${input("Image title", "title", "")}${input("Alt text", "alt", "")}${input("Folder or category", "category", "")} + +
${mediaGrid(media)} +
${media.map((m) => `

${escapeHtml(m.title || m.original_name)}

${escapeHtml(m.url)} · ${bytes(m.size_bytes)}

${csrf(res)}${input("Title", "title", m.title || "")}${input("Alt text", "alt_text", m.alt_text || "")}${input("Folder", "category", m.category || "")}
${csrf(res)}
`).join("")}
`; + renderAdmin(req, res, "Media Library", "media", body); +}); +app.post("/admin/media", requireAdmin, upload.array("images", 10), async (req, res, next) => { + try { + for (const file of req.files || []) await storeImage(file, req.body); + audit(req.admin.id, "Uploaded images", `${req.files?.length || 0} files`, req.ip); + res.redirect("/admin/media"); + } catch (e) { next(e); } +}); +app.post("/admin/media/:id", requireAdmin, (req, res) => { + db.prepare("UPDATE media SET title=?, alt_text=?, category=?, updated_at=? WHERE id=?").run(req.body.title, req.body.alt_text, req.body.category, now(), req.params.id); + res.redirect("/admin/media"); +}); +app.post("/admin/media/:id/delete", requireAdmin, (req, res) => { + const id = Number(req.params.id); + const used = [ + db.prepare("SELECT COUNT(*) count FROM items WHERE main_image_id=?").get(id).count, + db.prepare("SELECT COUNT(*) count FROM services WHERE image_id=?").get(id).count, + db.prepare("SELECT COUNT(*) count FROM equipment WHERE image_id=?").get(id).count, + db.prepare("SELECT COUNT(*) count FROM page_sections WHERE image_id=? OR background_image_id=?").get(id, id).count + ].reduce((sum, count) => sum + count, 0); + if (used) return res.status(400).send("This image is currently used on the website. Remove it from those pages or items before deleting it."); + const media = db.prepare("SELECT * FROM media WHERE id=?").get(id); + if (media) { + for (const url of [media.url, media.thumb_url]) { + if (!url) continue; + const filePath = path.join(config.root, "public", url.replace(/^\//, "")); + if (filePath.startsWith(config.uploadDir) && fs.existsSync(filePath)) fs.unlinkSync(filePath); + } + db.prepare("DELETE FROM media WHERE id=?").run(id); + audit(req.admin.id, "Deleted unused image", media.file_name, req.ip); + } + res.redirect("/admin/media"); +}); + +app.get("/admin/appearance", requireAdmin, (req, res) => settingsForm(req, res, "Appearance and Theme Settings", "appearance", [ + ["Main background color", "theme.bg", "color"], ["Secondary background color", "theme.bg_alt", "color"], ["Text color", "theme.text", "color"], ["Heading color", "theme.heading", "color"], ["Link color", "theme.link", "color"], ["Button color", "theme.button", "color"], ["Button text color", "theme.button_text", "color"], ["Accent color", "theme.accent", "color"], ["Border radius", "theme.radius", "number"], ["Site width", "theme.width", "number"], ["Heading font", "theme.heading_font"], ["Body font", "theme.body_font"], ["Footer style", "theme.footer_style"] +])); +app.post("/admin/appearance", requireAdmin, (req, res) => saveSettings(req, res, "appearance", "Updated appearance settings")); + +app.get("/admin/business", requireAdmin, (req, res) => settingsForm(req, res, "Business Settings", "business", [ + ["Business name", "business.name"], ["Tagline", "business.tagline"], ["Email", "business.email"], ["Phone", "business.phone"], ["Facebook link", "business.facebook"], ["Service area", "business.service_area"], ["Business hours", "business.hours"], ["Preferred contact method", "business.preferred_contact"], ["Footer text", "business.footer_text"], ["Copyright text", "business.copyright"], ["Website title", "site.title"], ["Website description", "site.description"] +])); +app.post("/admin/business", requireAdmin, (req, res) => saveSettings(req, res, "business", "Updated business settings")); + +app.get("/admin/seo", requireAdmin, (req, res) => settingsForm(req, res, "Search Engine and Sharing Settings", "seo", [ + ["Website title", "site.title"], ["Website description", "site.description"], ["Canonical URL", "seo.canonical"], ["Search-engine visibility", "seo.visible"] +])); +app.post("/admin/seo", requireAdmin, (req, res) => saveSettings(req, res, "seo", "Updated SEO settings")); + +app.get("/admin/maintenance", requireAdmin, (req, res) => settingsForm(req, res, "Maintenance Mode", "maintenance", [ + ["Maintenance enabled (true or false)", "maintenance.enabled"], ["Maintenance message", "maintenance.message"], ["Expected return message", "maintenance.return_note"], ["Artwork URL", "maintenance.artwork"] +])); +app.post("/admin/maintenance", requireAdmin, (req, res) => saveSettings(req, res, "maintenance", "Updated maintenance mode")); + +function settingsForm(req, res, title, active, fields) { + const s = allSettings(); + const body = `
${csrf(res)}${fields.map(([label, key, type]) => type === "textarea" ? textarea(label, key, setting(s, key)) : input(label, key, setting(s, key), type || "text")).join("")}
`; + renderAdmin(req, res, title, active, body); +} +function saveSettings(req, res, redirect, action) { + Object.entries(req.body).filter(([k]) => k !== "_csrf").forEach(([key, value]) => setSetting(key, value)); + audit(req.admin.id, action, "", req.ip); + res.redirect(`/admin/${redirect}`); +} + +app.get("/admin/navigation", requireAdmin, (req, res) => { + const rows = db.prepare("SELECT * FROM navigation ORDER BY display_order, id").all(); + const body = `
${rows.map((n) => `
${csrf(res)}${input("Label", "label", n.label)}${input("Link", "url", n.url)}${select("Kind", "kind", ["internal", "external"], n.kind)}${input("Order", "display_order", n.display_order, "number")}${checkboxInput("Show", "is_published", n.is_published)}${checkboxInput("Open in new tab", "opens_new_tab", n.opens_new_tab)}
`).join("")}
${csrf(res)}
`; + renderAdmin(req, res, "Navigation Manager", "navigation", body); +}); +app.post("/admin/navigation/new", requireAdmin, (req, res) => { + db.prepare("INSERT INTO navigation (label,url,kind,display_order,is_published) VALUES ('New Link','/','internal',100,0)").run(); + res.redirect("/admin/navigation"); +}); +app.post("/admin/navigation/:id", requireAdmin, (req, res) => { + const url = String(req.body.url || "/"); + if (url.startsWith("/admin")) return res.status(400).send("Administration links cannot be added to the public menu."); + db.prepare("UPDATE navigation SET label=?, url=?, kind=?, display_order=?, is_published=?, opens_new_tab=? WHERE id=?").run(req.body.label, url, req.body.kind, Number(req.body.display_order || 0), checkbox(req.body.is_published), checkbox(req.body.opens_new_tab), req.params.id); + res.redirect("/admin/navigation"); +}); + +app.get("/admin/security", requireAdmin, (req, res) => { + renderAdmin(req, res, "User and Security Settings", "security", `

Change Password

${csrf(res)}

Security Included

  • Secure password hashing
  • Session expiration
  • Login rate limiting
  • CSRF protection
  • Upload validation
  • Audit log for important changes
`); +}); +app.post("/admin/security", requireAdmin, async (req, res) => { + const row = db.prepare("SELECT * FROM administrators WHERE id=?").get(req.admin.id); + if (!(await bcrypt.compare(req.body.current_password || "", row.password_hash))) return res.status(400).send("Current password was not correct."); + if (String(req.body.new_password || "").length < 10) return res.status(400).send("Use at least 10 characters."); + db.prepare("UPDATE administrators SET password_hash=?, updated_at=? WHERE id=?").run(await bcrypt.hash(req.body.new_password, 12), now(), req.admin.id); + audit(req.admin.id, "Changed password", "", req.ip); + res.redirect("/admin/security"); +}); + +app.get("/admin/backups", requireAdmin, (req, res) => { + const rows = db.prepare("SELECT * FROM backups ORDER BY created_at DESC").all(); + renderAdmin(req, res, "Backup and Restore", "backups", `
${csrf(res)}

Backups include the SQLite database and uploaded images. Secret environment files are not included.

${rows.map((b) => `

${escapeHtml(b.file_name)}

${bytes(b.size_bytes)} · ${escapeHtml(b.created_at)}

Download Backup
`).join("") || "

No backups yet.

"}
`); +}); +app.post("/admin/backups/create", requireAdmin, async (req, res, next) => { + try { + const backup = await createBackup(); + audit(req.admin.id, "Created backup", backup.file_name, req.ip); + res.redirect("/admin/backups"); + } catch (e) { next(e); } +}); +app.get("/admin/backups/:id/download", requireAdmin, (req, res) => { + const backup = db.prepare("SELECT * FROM backups WHERE id=?").get(req.params.id); + if (!backup || !fs.existsSync(backup.file_path)) return res.status(404).send("Backup not found"); + res.download(backup.file_path, backup.file_name); +}); + +async function createBackup() { + fs.mkdirSync(config.backupDir, { recursive: true }); + const fileName = `debbie-windler-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`; + const filePath = path.join(config.backupDir, fileName); + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(filePath); + const archive = archiver("zip", { zlib: { level: 9 } }); + output.on("close", resolve); + archive.on("error", reject); + archive.pipe(output); + archive.file(config.databasePath, { name: "site.sqlite" }); + if (fs.existsSync(config.uploadDir)) archive.directory(config.uploadDir, "uploads"); + archive.finalize(); + }); + const stat = fs.statSync(filePath); + const result = db.prepare("INSERT INTO backups (file_name, file_path, size_bytes, created_at) VALUES (?, ?, ?, ?)").run(fileName, filePath, stat.size, now()); + return db.prepare("SELECT * FROM backups WHERE id=?").get(result.lastInsertRowid); +} + +app.use((err, req, res, next) => { + console.error(err); + const message = config.production ? "Something went wrong. Please try again." : err.message; + res.status(500).send(`

Something went wrong

${escapeHtml(message)}

`); +}); + +if (require.main === module) { + app.listen(config.port, () => { + console.log(`Debbie Windler Seamstress running at http://localhost:${config.port}`); + }); +} + +module.exports = { app, createBackup }; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..79dc8f7 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,84 @@ +const crypto = require("crypto"); +const sanitizeHtml = require("sanitize-html"); +const slugify = require("slugify"); + +function escapeHtml(value = "") { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function cleanRichText(value = "") { + return sanitizeHtml(String(value), { + allowedTags: ["p", "br", "strong", "b", "em", "i", "h2", "h3", "h4", "ul", "ol", "li", "a"], + allowedAttributes: { + a: ["href", "target", "rel"] + }, + allowedSchemes: ["http", "https", "mailto", "tel"], + transformTags: { + a: (tagName, attribs) => ({ + tagName, + attribs: { + href: attribs.href || "#", + target: attribs.target === "_blank" ? "_blank" : undefined, + rel: attribs.target === "_blank" ? "noopener noreferrer" : undefined + } + }) + } + }); +} + +function makeSlug(value, fallback = "entry") { + const slug = slugify(String(value || ""), { lower: true, strict: true, trim: true }); + return slug || `${fallback}-${Date.now()}`; +} + +function token(size = 24) { + return crypto.randomBytes(size).toString("hex"); +} + +function now() { + return new Date().toISOString(); +} + +function bytes(n = 0) { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`; +} + +function checkbox(value) { + return value ? 1 : 0; +} + +function listFromText(value = "") { + return String(value) + .split(/\r?\n|,/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function safeJson(value, fallback = {}) { + if (!value) return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +module.exports = { + escapeHtml, + cleanRichText, + makeSlug, + token, + now, + bytes, + checkbox, + listFromText, + safeJson +}; diff --git a/storage/server.pid b/storage/server.pid new file mode 100644 index 0000000..ebf57c3 --- /dev/null +++ b/storage/server.pid @@ -0,0 +1 @@ +32184 diff --git a/storage/server.stderr.log b/storage/server.stderr.log new file mode 100644 index 0000000..e69de29 diff --git a/storage/server.stdout.log b/storage/server.stdout.log new file mode 100644 index 0000000..e69de29 diff --git a/test/smoke.test.js b/test/smoke.test.js new file mode 100644 index 0000000..76c920a --- /dev/null +++ b/test/smoke.test.js @@ -0,0 +1,26 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const request = require("supertest"); +const { app } = require("../src/server"); + +test("public pages load", async () => { + for (const path of ["/", "/items", "/services", "/about-contact", "/healthz"]) { + const response = await request(app).get(path); + assert.equal(response.status < 500, true, `${path} returned ${response.status}`); + } +}); + +test("admin redirects to setup or login", async () => { + const response = await request(app).get("/admin"); + assert.equal([302, 303].includes(response.status), true); +}); + +test("contact form requires complete fields", async () => { + const agent = request.agent(app); + const page = await agent.get("/about-contact"); + const token = /name="_csrf" value="([^"]+)"/.exec(page.text)?.[1]; + const response = await agent + .post("/contact") + .send(`_csrf=${encodeURIComponent(token || "")}&name=&email=bad&subject=&message=`); + assert.equal(response.status, 400); +});