commit b27435ca57f271a991c713cef0a571650cee424d Author: Dario P. <83188092+donishki@users.noreply.github.com> Date: Sun Oct 25 00:24:42 2026 +0200 Introduce Daymark public edition with isolated deployment configuration diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d16bd23 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +dist +.git +.env +npm-debug.log* +README.md + +.git +.env +.env.* +*.dump +*.sql +backups/ +uploads/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..954c260 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Copy to .env and generate three different random secrets before starting. +DB_PASSWORD= +MINIO_PASSWORD= +AUTH_ENCRYPTION_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21ad56f --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +dist/ +*.tar +*.log +.DS_Store +.idea/ +.vscode/ + +.env +.env.* +!.env.example +*.dump +*.sql +*.tar.gz +backups/ +uploads/ +*.pem +*.key diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..af0eeb8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,613 @@ +# Changelog + +All notable changes to Daymark are documented here. The format follows Keep a Changelog, and releases use semantic versioning. + +## [Unreleased] + +## [1.18.1] - 2026-08-29 + +### Added + +- Automatically prepares a reusable share link when the Share panel opens. +- Encrypts recoverable share tokens at rest while retaining one-way hashes for public-link validation. + +### Changed + +- Reuses an active automatic link instead of creating a new link on every visit. +- Automatically replaces expired or revoked links while preserving manual password-protected link creation. + +## [1.18.0] - 2026-08-29 + +### Added + +- Expanded entry sharing with native device sharing, copy-link feedback, email, WhatsApp, Facebook, X, and Reddit actions. +- Added clear link-ready guidance and an active-link count to the sharing panel. + +### Changed + +- Clarified that creating a public link is the privacy boundary before external sharing options become available. + +## [1.17.1] - 2026-08-29 + +### Changed + +- Moved archive discovery beneath Journals in the main navigation sidebar. +- Replaced the oversized archive banner with a compact day count, random-entry action, and "On this day" list. + +## [1.17.0] - 2026-08-29 + +### Changed + +- Moved the diary name, description, entry count, recording year, and latest-entry date into the main top header. +- Removed the separate oversized diary masthead to reduce vertical space before the entry list. +- Preserved custom diary cover images as a subtle background on the new identity header. +- Kept account and administration routes on the smaller standard header. + +## [1.16.1] - 2026-08-28 + +### Added + +- Added a paperclip indicator beside Timeline titles when an entry contains attachments. +- Added an accessible hover label showing the number of attached files. + +## [1.16.0] - 2026-08-27 + +### Added + +- Added a themed photo-information overlay when hovering or focusing images inside diary entries. +- Added safe EXIF preservation for camera make and model, lens, aperture, shutter speed, ISO, focal length, dimensions, and capture time. +- Added file name and size information for existing images that do not contain preserved EXIF data. + +### Security + +- Excluded GPS coordinates from stored photo hover metadata so the overlay cannot bypass entry location-privacy settings. + +## [1.15.3] - 2026-08-26 + +### Added + +- Added the amber pin icon and `PINNED` label beside pinned entry titles in Timeline view. + +## [1.15.2] - 2026-08-26 + +### Fixed + +- Enforced pinned-first ordering after client-side edits, searches, journal filters, and view changes. +- Kept unpinned entries ordered by date, time, and entry ID after pinned entries. + +## [1.15.1] - 2026-08-26 + +### Changed + +- Removed the duplicate uppercase label from deleted-comment tombstones while retaining the message and timestamp. + +## [1.15.0] - 2026-08-26 + +### Added + +- Added tombstone deletion for comments with replies so the discussion tree remains intact. +- Added automatic pruning when a deleted parent no longer has any replies beneath it. + +### Changed + +- Deleted parent comments now hide their text, author identity, reactions, and controls while preserving their replies. +- Leaf comments continue to be removed completely, and deleted comments are excluded from active counts and moderation queues. +- Updated the deletion modal to explain whether a comment will become a tombstone or be permanently removed. + +## [1.14.12] - 2026-08-26 + +### Added + +- Added a designed confirmation modal before permanently deleting a comment. +- Added a comment preview and an explicit warning that nested replies are deleted with their parent. +- Added Escape, outside-click, loading, and deletion-error handling to comment confirmation. + +## [1.14.11] - 2026-08-26 + +### Changed + +- Moved Reply and Delete into a compact row directly beneath each comment timestamp. +- Simplified comment footers so they are dedicated exclusively to reaction controls and summaries. + +## [1.14.10] - 2026-08-26 + +### Changed + +- Separated comment footer controls into Like on the left, Reply and Delete on the right, and chosen reactions at the far edge. +- Added a subtle divider between comment actions and the chosen-reaction stack when reactions exist. + +## [1.14.9] - 2026-08-26 + +### Fixed + +- Kept comment and reply reaction triggers as thumbs-up icons after choosing another reaction. +- Removed the duplicate inline selected emoji and count; chosen reactions now appear only in the right-side stack. + +## [1.14.8] - 2026-08-26 + +### Changed + +- Reorganized comment footers with reaction, Reply, and Delete controls grouped on the left. +- Added Facebook-style chosen-reaction stacks on the right of every comment and nested reply. +- Added participant-name tooltips to comment and reply reaction summaries. + +## [1.14.7] - 2026-08-26 + +### Changed + +- Simplified the discussion header by removing the repeated diary entry title. + +## [1.14.6] - 2026-08-26 + +### Fixed + +- Rendered discussions as real parent-child trees so replies appear directly beneath the comment they answer. +- Added progressive indentation for replies of replies, capped at six levels with tighter spacing on mobile. + +## [1.14.5] - 2026-08-26 + +### Changed + +- Removed the Like and Reacted text labels from reaction controls on nested replies. +- Kept reply reaction hover, touch, selection, count, and participant behavior consistent with top-level comments. + +## [1.14.4] - 2026-08-26 + +### Changed + +- Changed reaction-summary badges to use the standard hand pointer instead of the help cursor. + +## [1.14.3] - 2026-08-26 + +### Changed + +- Simplified the entry reaction tray to show only available emoji choices without selected borders or counts. +- Kept reaction selections and participant details exclusively in the summary stack on the right. + +## [1.14.2] - 2026-08-26 + +### Added + +- Added a compact overlapping stack of the reactions chosen for an entry on the right side of its social bar. +- Added per-reaction hover labels listing the people who selected each emoji. + +## [1.14.1] - 2026-08-26 + +### Changed + +- Made the left thumbs-up reaction count open the emoji tray on hover or tap. +- Removed the separate blue reaction button from the right side of the discussion bar. + +## [1.14.0] - 2026-08-26 + +### Added + +- Added OLED Black, Nord, Dracula, Gruvbox, Solarized Dark, Forest, Rose Pine, and Monochrome themes. + +### Changed + +- Rebuilt theme support around shared semantic colour tokens so backgrounds, panels, borders, text, controls, and status colours change together. +- Replaced filter-based light themes with native palettes so images, videos, maps, and charts retain their original colours. + +## [1.13.3] - 2026-08-26 + +### Changed + +- Reworked entry reactions into a Facebook-style social bar with reaction and comment totals. +- Replaced the labeled entry reaction control with a single circular Like button aligned to the right. +- Made reaction trays open on hover for pointer devices while retaining click support for touch screens. + +## [1.13.2] - 2026-08-26 + +### Changed + +- Replaced permanently visible emoji rows with a compact Facebook-style React control. +- Added an on-demand reaction picker, existing-reaction summary, selected state, and outside-click dismissal. + +## [1.13.1] - 2026-08-25 + +### Changed + +- Repositioned comment reactions into a compact footer directly beneath each comment. +- Kept emoji reactions left-aligned and grouped Reply and Delete actions on the right. +- Removed the oversized global footer spacing from comment controls. + +## [1.13.0] - 2026-08-25 + +### Changed + +- Reorganized User Settings from nineteen fragmented destinations into ten task-focused sections. +- Grouped diary and editor controls, journals and tags, notification tools, privacy tools, and data management into cohesive pages. +- Preserved old settings links by redirecting their hashes to the appropriate new section. +- Tightened the settings sidebar and added consistent spacing between grouped panels. + +## [1.12.4] - 2026-08-25 + +### Added + +- Added a visible pin icon and label beside the title of every pinned diary entry. +- Added a designed, accessible confirmation modal when moving an entry to Trash. + +## [1.12.3] - 2026-08-25 + +### Changed + +- Shortened the shared entry visibility option label to "Shared". + +## [1.12.2] - 2026-08-25 + +### Changed + +- Combined Encrypt Text and Pin Entry into one compact, equally sized composer options row. +- Removed the oversized encryption container while retaining a focused passphrase area only when encryption is enabled. + +## [1.12.1] - 2026-08-25 + +### Removed + +- Removed Knowledge Graph from the diary view selector and application view routing. + +### Fixed + +- Legacy Knowledge Graph URLs and saved default values now fall back safely to Diary instead of rendering an empty view. + +## [1.12.0] - 2026-08-25 + +### Fixed + +- Corrected personal Statistics totals by normalizing account and entry owner identifiers before comparison. + +### Added + +- Redesigned Statistics with interactive summary cards, weekly and monthly goal progress, a 12-month activity chart, weekday writing distribution, a visibility donut, and ranked topic bars. +- Included zero-activity months in the annual chart so trends remain continuous instead of disappearing. + +## [1.11.2] - 2026-08-25 + +### Added + +- Gallery images now open their original full-size file in an in-page lightbox with background-click, close-button, and Escape-key dismissal. + +## [1.11.1] - 2026-08-25 + +### Fixed + +- Fixed a strict-mode variable declaration error that caused a blank page when opening the date-grouped Gallery. + +## [1.11.0] - 2026-08-25 + +### Added + +- Grouped Gallery photos and videos under localized date headings, similar to the Windows Photos timeline. +- Added per-day media counts and entry-page links to Gallery captions. + +## [1.10.3] - 2026-08-25 + +### Added + +- Timeline rows now open their corresponding entry pages by mouse click or keyboard activation, with clear hover and focus feedback. + +## [1.10.2] - 2026-08-25 + +### Changed + +- Replaced wrapping ISO dates in Timeline with compact day and month/year blocks. +- Added each entry’s time to Timeline using the account’s configured 12- or 24-hour format. + +## [1.10.1] - 2026-08-25 + +### Fixed + +- Scoped calendar day filtering to Timeline so switching to another diary view automatically restores all dates. +- Added a visible “Show all dates” action and selected-date heading to filtered Timeline views. + +## [1.10.0] - 2026-08-25 + +### Added + +- Populated calendar days now open the Timeline filtered to every entry recorded on that exact date. +- Calendar-to-timeline navigation stores the selected date in the URL so the filtered day can be bookmarked or reloaded. + +## [1.9.13] - 2026-08-25 + +### Fixed + +- Moved the private-entry lock indicator to immediately after the Delete action. + +## [1.9.12] - 2026-08-25 + +### Changed + +- Replaced the “PRIVATE” entry metadata text with a compact lock indicator beside the Delete action. + +## [1.9.11] - 2026-08-25 + +### Fixed + +- Added matching hover and pressed feedback to Take Photo so it behaves consistently with Record Voice. + +## [1.9.10] - 2026-08-25 + +### Fixed + +- Stopped the Bold toolbar button from appearing selected while hovering the entry editor by separating the editor controls from an invalid shared label relationship. + +## [1.9.9] - 2026-08-25 + +### Changed + +- Removed collaboration-role suffixes such as “owner” from journal names in the entry composer. + +## [1.9.8] - 2026-08-25 + +### Changed + +- Removed the Mood field, visible coordinate inputs, and manual weather-loading control from the entry composer. +- Kept map coordinates internal so location search and mapped entries continue to work without exposing technical fields. + +## [1.9.7] - 2026-08-25 + +### Fixed + +- Matched the Take Photo and Record Voice composer controls to the same width and height in normal, disabled, and recording states. + +## [1.9.6] - 2026-08-25 + +### Changed + +- Removed the template-button and rotating-prompt writing aids from the entry composer for a cleaner editing surface. + +## [1.9.5] - 2026-08-25 + +### Changed + +- Shortened the User Settings sidebar labels by removing “preferences” from Diary, Editor, Notifications, and Privacy. + +## [1.9.4] - 2026-08-25 + +### Changed + +- Redesigned journal cards with a balanced details-and-controls layout, clearer action hierarchy, larger controls, and compact entry metadata. +- Tightened the Create Journal area and aligned it with the journal cards across desktop and mobile layouts. + +## [1.9.3] - 2026-08-25 + +### Fixed + +- Prevented the Journals settings page from rendering blank on its first visit by replacing its legacy modal lifecycle with a stable page panel and defensively handling incomplete journal records. + +## [1.9.2] - 2026-08-25 + +### Changed + +- Reworked Journal settings into clean full-width rows with separated metadata and actions, plus a labelled full-width creation area. +- Improved Journal settings alignment and responsive behaviour on narrow screens. + +## [1.9.1] - 2026-08-25 + +### Changed + +- Removed category headings from the User Settings sidebar for a cleaner uninterrupted navigation list. + +## [1.9.0] - 2026-08-25 + +### Changed + +- Replaced nested User Settings tabs with direct, grouped sidebar destinations for every profile, diary, writing, interface, notification, privacy, security, and data panel. +- Each settings subsection now has its own stable URL fragment for direct links and bookmarks. + +## [1.8.1] - 2026-08-25 + +### Changed + +- Simplified Profile settings by removing the Pronouns and Home Location fields. + +## [1.8.0] - 2026-08-25 + +### Added + +- Self-service temporary account deactivation and credential-based reactivation from the sign-in page. +- Journal ownership transfer to another active user while retaining editor access. +- Recoverable account deletion with a 30-day cancellation window and automatic removal of database records, media, and personal backup files. +- Downloadable CSV account-security activity alongside existing device-session controls. + +## [1.7.0] - 2026-08-25 + +### Added + +- Personal storage analytics with category graphs, quota progress, a largest-file browser, and exact duplicate detection. +- Safe attachment cleanup that removes selected originals and thumbnails from included object storage and every referencing entry. +- On-demand personal backup history with complete account data, readable Markdown entries, original media, and download or deletion controls. + +## [1.6.0] - 2026-08-25 + +### Added + +- Independent in-app, native browser, and verified-email notification channels with quiet hours and instant, daily, or weekly email delivery. +- Shared-entry creation/update notifications and security alerts for failed sign-ins and new devices. +- Background email delivery with retry tracking and grouped digest messages through the included SMTP service. + +### Changed + +- Notification event preferences now govern comments, replies, mentions, shared journal activity, and account security across enabled delivery channels. + +## [1.5.0] - 2026-08-25 + +### Added + +- Direct profile-image and diary-cover uploads to included object storage, with controlled public avatar delivery for public author pages. +- Personal tag inventory with account-wide rename, merge, and removal actions. +- Custom composer templates and prompts, scheduled browser writing prompts, and configurable weekly-entry and monthly-word goals. +- Live writing-goal progress within personal diary statistics. + +### Changed + +- Composer templates and random prompts now include each user's saved writing tools, and statistics isolate personal authorship within collaborative journals. + +## [1.4.0] - 2026-08-25 + +### Added + +- Optional public author pages with profile image, pronouns, biography, personal website, join date, and public entries. +- Account-wide public-link management, held-comment review and approval, and blocked-user management inside Privacy settings. +- Database-backed comment approval and mutual interaction blocking for comments, reactions, and mentions. + +### Changed + +- Privacy settings now control public-profile availability and whether new comments require owner approval. + +## [1.3.0] - 2026-08-25 + +### Added + +- Functional date, time, timezone, temperature, distance, week-start, map-zoom, image-quality, remembered-entry, accent-colour, diary-cover, and expanded identity preferences. +- Client-side image resizing and JPEG quality selection for new uploads while retaining an original-file option. + +### Changed + +- Calendar alignment, entry timestamps, weather units, single-location map zoom, composer defaults, remembered entry choices, interface accents, and masthead covers now follow each account's saved preferences. + +## [1.2.0] - 2026-08-25 + +### Added + +- Profile editing and a dedicated Privacy page for location precision, comment access, and default public-link expiry. +- Comment and reply inbox notifications that respect each recipient's saved notification preferences. + +### Changed + +- Consolidated User Settings into exactly seven full-page sections: Profile, Diary, Editor, Appearance, Notifications, Privacy, and Security & Data. +- Moved journals, inbox activity, sessions, exports, account lifecycle, and trash into focused sub-navigation within their related settings pages. +- Applied saved preferences to the opening diary view, entry-list size, composer journal and visibility defaults, autosave, spellcheck, interface theme/scale/density/motion, location precision, comments, and public-link expiry. + +## [1.1.0] - 2026-08-25 + +### Added + +- Account-synchronized user preferences backed by PostgreSQL and authenticated preference APIs. +- New full-page Diary, Editor, Appearance, and Notification preference sections with responsive controls and saved-state feedback. + +## [1.0.6] - 2026-08-25 + +### Changed + +- Flattened the Maintenance page into a full-width layout without the nested modal container, with tighter status, schedule, message, behavior, and action sections. + +## [1.0.5] - 2026-08-25 + +### Changed + +- Administrator account creation now uses the same focused modal as user editing for a consistent Users workflow. + +## [1.0.4] - 2026-08-25 + +### Changed + +- User editing now opens the existing administrator editor in a focused modal while preserving its established appearance and controls. + +## [1.0.3] - 2026-08-25 + +### Changed + +- Simplified Operational Alerts with a compact health strip, full-width threshold layout, evenly aligned setting cards, and a cleaner action row. + +## [1.0.2] - 2026-08-25 + +### Changed + +- Compacted the seven Admin Overview headline metrics into one complete desktop row, with balanced full-width wrapping on small screens. + +## [1.0.1] - 2026-08-25 + +### Changed + +- Reflowed Admin Overview metrics so incomplete rows expand to the available width without leaving empty colored grid cells. + +## [1.0.0] - 2026-08-25 + +### Added + +- Scheduled maintenance mode with optional immediate start/open-ended duration, visitor messaging, HTTP 503 API enforcement, automatic expiry, administrator bypass, and audit events. +- Date-ranged administrator analytics exports in spreadsheet-ready CSV and formatted PDF, with report generation audit events. +- Configurable operational alerts for memory, PostgreSQL connections, failed sign-ins, backup age/completeness, and per-user storage utilization. +- Configurable per-user storage quotas with live usage in account administration and enforcement during uploads. +- Unified administrator moderation for recent comments and public links with search, context links, removal/revocation actions, and audit events. +- Admin backup history with matched database/media status, total sizes, one-click creation, authenticated downloads, deletion, and audit events. +- Persistent administrator audit logging with actor, action, target, details, IP address, and a dedicated Admin Audit page. +- Live Admin Overview charts for 30-day content and sign-in activity, entry visibility, and top contributors using lightweight native SVG and CSS. +- Administrator Website settings for the live site name, masthead description, top notice, browser title, and footer signature. +- Full administrator account creation and editing, password resets, verification controls, guarded account deletion, and detailed host/container and PostgreSQL resource statistics. +- Expanded administration with overview metrics, searchable account management, session revocation, registration-code history and revocation, sign-in auditing, and system/storage status. +- Repository README with feature, setup, Portainer, migration, backup, configuration, and security guidance. +- Permission-checked standalone entry pages at `/entries/:id` for durable diary permalinks. +- Editable entry times, displayed alongside each diary date and backfilled from original creation timestamps for existing entries. +- Dedicated `/settings` and `/admin` pages with section navigation, replacing account and administration popups. + +### Changed + +- Removed the manually maintained revision label from System status to prevent stale deployment information. +- Removed the inherited modal form container from Website settings so its fields use the full page content area. +- Removed the redundant nested Identity card from the single-section Website settings page. +- Rebuilt registration administration as two clearly labeled cards for access policy and invitation-code creation. +- Added consistent pointer, hover, pressed, and keyboard-focus feedback to interactive controls and popup close buttons across the site. +- Reorganized administrator user rows into aligned account details, status controls, and grouped actions with a responsive stacked layout. +- Limited Admin Dashboard status messages to their current section and made them dismiss automatically. +- Rebuilt the Admin Dashboard as a full-page, sectioned interface matching the User Settings navigation pattern. +- Increased the complete interface scale by 10%, including typography, controls, icons, spacing, and panels. +- Flattened form panels by removing their offset 3D shadows while retaining clear borders. +- Hid the redundant Discuss link when viewing an entry's already-open discussion page. +- Renamed the user-facing application from Daymark to Daymark while preserving its existing Docker and storage identifiers. +- Replaced the oversized static hero with a compact masthead showing the live entry count, earliest recorded year, and latest entry date. +- Renamed Permalink to an icon-labeled Discuss link and embedded reactions, comments, replies, and comment creation directly beneath standalone entries. +- Removed the Latest badge and moved entry discussions from the heading actions to a dedicated control beneath each entry. +- Made diary entry titles link directly to their standalone entry pages. +- Made entry tags navigable filters that reopen the diary at the matching tag and year. +- Removed automatic tag-based Related Entry links from diary cards to reduce visual clutter; manual links and the Knowledge Graph remain available. +- Expanded entry headings into a two-column date/content layout with title and actions on top and metadata/location spanning the full row below. +- Reordered entry headings with the title first, metadata beneath it, and location aligned across the metadata row. +- Replaced the crowded seven-button view switcher with a compact View selector and separated Search, Filters, and entry count controls. +- Positioned New Entry beside User Settings in the simplified diary header. +- Opt-in live voice transcription through supported browser speech services and on-demand in-browser Tesseract OCR that inserts extracted image text into drafts. +- SSRF-protected web link metadata previews with bounded fetches, redirect validation, image cards, and privacy-enhanced YouTube/Vimeo embeds. +- OpenStreetMap/Nominatim place search, client-side photo EXIF date/GPS extraction, and date/location-specific historical or forecast weather from Open-Meteo. +- Mobile rear-camera capture, browser microphone voice-note recording, stored entry reminder times, and PWA/browser reminder notifications. +- Installable Daymark PWA with an offline application shell, locally persistent drafts, an auto-retrying offline entry outbox, app shortcuts, and operating-system text/link sharing into the composer. +- Optional browser-side AES-256-GCM entry encryption using a 310,000-round PBKDF2 passphrase key, opaque server storage, locked entry placeholders, and explicit unlock controls. +- Complete account JSON/ZIP exports spanning profile, journals, entries, revisions, collaboration, activity, security metadata, and original media, plus verified permanent account deletion. +- WebP image thumbnails with original-file links, attachment captions and removal, live upload progress, and an enforced 5 GB per-user storage quota. +- Multiple journals per account with automatic migration, entry assignment, colors, descriptions, defaults, visibility, sorting, filtering, and management. +- Expiring journal invitation links, email targeting, member management, and server-enforced owner, editor, contributor, and viewer roles. +- Entry comments and replies, emoji reactions, member mentions with an inbox, and per-journal activity history. +- Revocable public entry links with expiration, optional password protection, isolated media access grants, and view counts. +- Structured entry links and backlinks, reusable people, journal collections, and an interactive relationship graph. +- Email verification and expiring password-reset links delivered through an included persistent Mailpit SMTP inbox. +- Encrypted TOTP two-factor authentication with QR setup, login challenges, and single-use recovery codes. +- WebAuthn passkey registration, device management, and phishing-resistant passwordless login. +- Active-device management with individual/global session revocation, last-seen metadata, and successful/failed login history. +- Double-submit CSRF protection for authenticated mutations and layered rate limits for authentication, recovery, public access, uploads, and API writes. +- Administrator dashboard with site totals, open/invitation/closed registration modes, expiring multi-use codes, admin promotion, and account disabling. +- Markdown authoring and safe rendering with a formatting toolbar, checklists, and live preview. +- Locally autosaved and recovered entry drafts, word and character counts, full-screen writing, and keyboard shortcuts. +- Reusable reflection, gratitude, travel, and project templates with rotating writing prompts. +- Calendar, compact timeline, mixed-media gallery, and interactive global OpenStreetMap discovery views. +- Indexed PostgreSQL full-text search with date, mood, visibility, media, location, ownership, and tag filters. +- Tag-ranked related entries, random archive discovery, and date-matched “On this day” memories. +- Writing statistics with word, entry, attachment, and topic totals, monthly activity, and current/longest streaks. +- MinIO object storage for new media attachments, with authenticated media delivery and 50 MB per-file uploads. +- Recoverable entry deletion with an owner-only trash, restore, and permanent-delete workflow. +- Automatic revision snapshots before entry edits. +- Owner-only revision history browser with one-click restoration and preservation of the replaced version. +- Included daily PostgreSQL and MinIO media backups with 14-day retention and persistent backup storage. +- Whole-journal JSON, printable PDF, and Markdown/media ZIP exports plus validated JSON import. +- Initial multi-user chronological diary application. +- PostgreSQL persistence with automatic migrations. +- Secure cookie sessions and password hashing. +- Private and shared entries. +- Mixed-media attachments, maps, moods, tags, pinning, search, and archives. +- Owner-only entry editing. + +### Fixed + +- Prevented shared-network database DNS collisions. +- Normalized PostgreSQL dates to ISO format. +- Replaced placeholder archive navigation with working year and month navigation. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..78e911a --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,21 @@ +# Daymark deployment + +Copy `.env.example` to `.env` and fill in all three secret values. Keep `.env` private and backed up securely. Losing the authentication encryption key can make protected authentication data unreadable. + +Start with `docker compose up -d --build`. Inspect with `docker compose ps` and `docker compose logs -f diary`. The app listens on port 3000; the development mail inbox listens on port 8025. + +For Portainer, use this repository and docker-compose.yml, and provide DB_PASSWORD, MINIO_PASSWORD, and AUTH_ENCRYPTION_KEY as stack environment variables. Configure a reverse proxy for HTTPS and set COOKIE_SECURE=true. Configure appropriate production SMTP delivery before relying on account emails. + +## Independent installations + +The default Compose project is daymark. To create a separate installation, use `docker compose -p another-name up -d --build` and choose different published ports. Compose scopes all volume and network names by the project. Do not reuse an existing personal deployment's database or media volumes. + +## Backups + +The backup service writes database dumps and media archives every 24 hours and retains 14 days. Copy these off the server regularly and test restoration. The admin dashboard also supports backup management. + +Use `docker compose exec backup ls -lh /backups` to inspect backups. `docker compose cp backup:/backups ./backups` copies them locally. Backups contain private data and must not be committed to Git. + +`docker compose down` preserves named volumes. Adding `-v` deletes persistent data. + +To migrate an existing diary, arrange a maintenance window, back up the database and media together, preserve authentication keys, and restore into explicitly selected new volumes. Source code alone does not contain diary entries or uploaded media. Do not change a live personal deployment's project name or volumes as part of a source-folder rename. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e03c52d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine AS production +ENV NODE_ENV=production PORT=3000 +WORKDIR /app +RUN apk add --no-cache postgresql-client +COPY package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force +COPY --from=build /app/dist ./dist +COPY server.mjs ./ +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1 +CMD ["node","server.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0db47bc --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Daymark + +A self-hosted, multi-user diary for days, places, people, and ideas. + +Daymark combines Markdown journaling, photos and media, maps, calendar and timeline views, search, sharing, account exports, and administration. It includes password authentication, TOTP, passkeys, and an installable web app. + +## Run with Docker + +1. Copy `.env.example` to `.env`. +2. Set three different strong random secrets in `.env`. +3. Run `docker compose up -d --build`. +4. Open `http://localhost:3000` and create your first account. + +The first account becomes administrator. The included Mailpit development inbox is available on port 8025. Configure production email and HTTPS before making an installation available online; set `COOKIE_SECURE` to `true` behind HTTPS. + +PostgreSQL, media storage, mail, and backups use Compose-project-scoped volumes. Use a distinct project name for every installation. Never point a test or public instance at another installation's database or volumes. + +`docker-compose.standalone.yml` is an equivalent portable configuration. See [deployment instructions](DEPLOYMENT.md). + +## Development + +Use Node.js 22 or later. Run `npm ci` and `npm run build` to build the frontend. `npm run dev` starts Vite; the backend requires the database and storage configuration supplied by Docker Compose. + +## Repositories + +- [GitHub](https://github.com/donishki/Daymark) +- [Gitea](https://git.ptbox.org/dasp/Daymark) + +This public edition starts with clean source history. Personal deployment configuration and diary contents are excluded. Database and media content live in deployment volumes, not this source repository. + +Legacy storage keys and export identifiers are retained for compatibility with existing backups. + +## License + +No open-source license has been selected. All rights reserved until a license is added. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0fbed80 --- /dev/null +++ b/TODO.md @@ -0,0 +1,71 @@ +# Daymark roadmap + +## P0 — Storage and data safety + +- [x] Replace base64 database attachments with included MinIO object storage. +- [x] Generate image thumbnails and retain original files. +- [x] Add upload progress, captions, attachment removal, and storage quotas. +- [x] Add soft deletion and a trash/recovery workflow. +- [x] Add entry revision history and restore. +- [x] Add automatic backups and documented restore verification. +- [x] Add JSON, Markdown, PDF, and media-inclusive ZIP export. +- [x] Add JSON import and portable Markdown ingestion through Daymark archives. + +## P1 — Writing and discovery + +- [x] Add a Markdown/rich-text editor with formatting and checklists. +- [x] Add autosaved drafts and recovery. +- [x] Add word counts, full-screen writing, and keyboard shortcuts. +- [x] Add reusable templates and prompts. +- [x] Add calendar, timeline, gallery, and global map views. +- [x] Add PostgreSQL full-text search and advanced filters. +- [x] Add related, random, and “On this day” entries. +- [x] Add streaks and writing statistics. + +## P2 — Journals and collaboration + +- [x] Add multiple journals per user. +- [x] Add journal colors, defaults, visibility, and sorting. +- [x] Add invitations and owner/editor/contributor/viewer roles. +- [x] Add comments, reactions, mentions, and activity history. +- [x] Add expiring and password-protected public links. +- [x] Add entry links, backlinks, people, collections, and graph view. + +## P3 — Accounts and privacy + +- [x] Add password reset and email verification. +- [x] Add two-factor authentication and passkeys. +- [x] Add session/device management and login history. +- [x] Add CSRF protection and rate limiting. +- [x] Add registration controls and an administrator dashboard. +- [x] Add account deletion and complete data export. +- [x] Design and implement optional client-side end-to-end encryption. + +## P4 — Mobile and enrichment + +- [x] Add installable PWA support and offline drafts. +- [x] Add background synchronization and share-to-diary. +- [x] Add mobile photo capture, voice recording, and reminders. +- [x] Add location search, EXIF extraction, and automatic weather. +- [x] Add link previews and rich video embeds. +- [x] Add optional audio transcription and document OCR. + +## P5 - User settings + +- [x] Add persisted account preferences and authenticated settings APIs. +- [x] Add Diary, Editor, Appearance, and Notification preference pages. +- [x] Add Profile and Privacy preference pages. +- [x] Consolidate security, sessions, exports, and account lifecycle into Security & Data. +- [x] Apply saved diary, editor, appearance, notification, and privacy preferences throughout the application. + +## P6 - Complete user settings feature set + +- [x] Add and apply date, time, timezone, measurement, and week-start preferences. +- [x] Add editor mode, image quality, map zoom, and remembered entry defaults. +- [x] Add accent colour, interface scale/density, reduced motion, compact entries, and custom diary covers. +- [x] Complete public identity with uploaded avatars and public author pages. +- [x] Add global public-link management, comment approval, and blocked users. +- [x] Add browser/email delivery, shared-entry and security alerts, quiet hours, and digests. +- [x] Add storage graphs, largest-file tools, duplicate detection, and personal backup history. +- [x] Add tag management, recurring prompts, configurable reminders, and writing goals. +- [x] Add deactivation, journal ownership transfer, and delayed account deletion. diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml new file mode 100644 index 0000000..4bd4f2a --- /dev/null +++ b/docker-compose.standalone.yml @@ -0,0 +1,113 @@ +name: daymark +services: + database: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: diary + POSTGRES_USER: diary_user + POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + volumes: + - diary_database:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + networks: [diary_internal] + diary: + build: . + image: daymark:latest + restart: unless-stopped + ports: + - "3000:3000" + environment: + DB_HOST: database + DB_PORT: 5432 + DB_NAME: diary + DB_USER: diary_user + DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + DATABASE_SSL: "false" + DATABASE_POOL_SIZE: 10 + JSON_LIMIT: 30mb + COOKIE_SECURE: "false" + MINIO_HOST: minio + MINIO_PORT: 9000 + MINIO_BUCKET: field-log-media + MINIO_ACCESS_KEY: fieldlog + MINIO_SECRET_KEY: "${MINIO_PASSWORD:?Set MINIO_PASSWORD in .env}" + MEDIA_FILE_LIMIT: 52428800 + STORAGE_QUOTA: 5368709120 + SMTP_HOST: mail + SMTP_PORT: 1025 + MAIL_FROM: "Daymark " + AUTH_ENCRYPTION_KEY: "${AUTH_ENCRYPTION_KEY:?Set AUTH_ENCRYPTION_KEY in .env}" + BACKUP_PATH: /backups + volumes: + - diary_backups:/backups + - diary_media:/minio-data:ro + depends_on: + database: + condition: service_healthy + minio: + condition: service_healthy + networks: [diary_internal] + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: fieldlog + MINIO_ROOT_PASSWORD: "${MINIO_PASSWORD:?Set MINIO_PASSWORD in .env}" + volumes: + - diary_media:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 10 + networks: [diary_internal] + backup: + image: postgres:17-alpine + restart: unless-stopped + environment: + PGHOST: database + PGPORT: 5432 + PGDATABASE: diary + PGUSER: diary_user + PGPASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + command: + [ + "/bin/sh", + "-ec", + "chown 1000:1000 /backups; while true; do stamp=$$(date -u +%Y-%m-%dT%H-%M-%SZ); pg_dump --format=custom --file=/backups/diary-$${stamp}.dump; tar -czf /backups/media-$${stamp}.tar.gz -C /minio-data .; find /backups -type f -mtime +14 -delete; sleep 86400; done", + ] + volumes: + - diary_backups:/backups + - diary_media:/minio-data:ro + depends_on: + database: + condition: service_healthy + minio: + condition: service_healthy + networks: [diary_internal] + mail: + image: axllent/mailpit:latest + restart: unless-stopped + ports: + - "8025:8025" + environment: + MP_MAX_MESSAGES: 5000 + MP_DATABASE: /data/mailpit.db + volumes: + - diary_mail:/data + networks: [diary_internal] +volumes: + diary_database: + diary_media: + diary_backups: + diary_mail: +networks: + diary_internal: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4bd4f2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,113 @@ +name: daymark +services: + database: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: diary + POSTGRES_USER: diary_user + POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + volumes: + - diary_database:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + networks: [diary_internal] + diary: + build: . + image: daymark:latest + restart: unless-stopped + ports: + - "3000:3000" + environment: + DB_HOST: database + DB_PORT: 5432 + DB_NAME: diary + DB_USER: diary_user + DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + DATABASE_SSL: "false" + DATABASE_POOL_SIZE: 10 + JSON_LIMIT: 30mb + COOKIE_SECURE: "false" + MINIO_HOST: minio + MINIO_PORT: 9000 + MINIO_BUCKET: field-log-media + MINIO_ACCESS_KEY: fieldlog + MINIO_SECRET_KEY: "${MINIO_PASSWORD:?Set MINIO_PASSWORD in .env}" + MEDIA_FILE_LIMIT: 52428800 + STORAGE_QUOTA: 5368709120 + SMTP_HOST: mail + SMTP_PORT: 1025 + MAIL_FROM: "Daymark " + AUTH_ENCRYPTION_KEY: "${AUTH_ENCRYPTION_KEY:?Set AUTH_ENCRYPTION_KEY in .env}" + BACKUP_PATH: /backups + volumes: + - diary_backups:/backups + - diary_media:/minio-data:ro + depends_on: + database: + condition: service_healthy + minio: + condition: service_healthy + networks: [diary_internal] + minio: + image: minio/minio:latest + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: fieldlog + MINIO_ROOT_PASSWORD: "${MINIO_PASSWORD:?Set MINIO_PASSWORD in .env}" + volumes: + - diary_media:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 10 + networks: [diary_internal] + backup: + image: postgres:17-alpine + restart: unless-stopped + environment: + PGHOST: database + PGPORT: 5432 + PGDATABASE: diary + PGUSER: diary_user + PGPASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + command: + [ + "/bin/sh", + "-ec", + "chown 1000:1000 /backups; while true; do stamp=$$(date -u +%Y-%m-%dT%H-%M-%SZ); pg_dump --format=custom --file=/backups/diary-$${stamp}.dump; tar -czf /backups/media-$${stamp}.tar.gz -C /minio-data .; find /backups -type f -mtime +14 -delete; sleep 86400; done", + ] + volumes: + - diary_backups:/backups + - diary_media:/minio-data:ro + depends_on: + database: + condition: service_healthy + minio: + condition: service_healthy + networks: [diary_internal] + mail: + image: axllent/mailpit:latest + restart: unless-stopped + ports: + - "8025:8025" + environment: + MP_MAX_MESSAGES: 5000 + MP_DATABASE: /data/mailpit.db + volumes: + - diary_mail:/data + networks: [diary_internal] +volumes: + diary_database: + diary_media: + diary_backups: + diary_mail: +networks: + diary_internal: + driver: bridge diff --git a/index.html b/index.html new file mode 100644 index 0000000..99625f8 --- /dev/null +++ b/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + Daymark + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7cc4e00 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5892 @@ +{ + "name": "daymark", + "version": "1.18.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "daymark", + "version": "1.18.1", + "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", + "@vitejs/plugin-react": "latest", + "archiver": "^8.0.0", + "exifr": "^7.1.3", + "express": "^5.1.0", + "express-rate-limit": "^8.6.2", + "leaflet": "^1.9.4", + "lucide-react": "^0.468.0", + "minio": "^8.0.7", + "multer": "^2.2.0", + "nodemailer": "^9.0.5", + "pdfkit": "^0.20.1", + "pg": "^8.16.3", + "qrcode": "^1.5.4", + "react": "latest", + "react-dom": "latest", + "react-leaflet": "^5.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "sharp": "^0.35.3", + "tesseract.js": "^7.0.0", + "vite": "latest" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz", + "integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", + "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz", + "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "is-stream": "^4.0.0", + "lazystream": "^1.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^3.0.0", + "tar-stream": "^3.0.0", + "zip-stream": "^7.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.0.tgz", + "integrity": "sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", + "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/block-stream2": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", + "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", + "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/compress-commons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz", + "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^7.0.1", + "is-stream": "^4.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz", + "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.0.tgz", + "integrity": "sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minio": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz", + "integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.4", + "block-stream2": "^2.1.0", + "browser-or-node": "^2.1.1", + "buffer-crc32": "^1.0.0", + "eventemitter3": "^5.0.1", + "fast-xml-parser": "^5.3.4", + "ipaddr.js": "^2.0.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "query-string": "^7.1.3", + "stream-json": "^1.8.0", + "through2": "^4.0.2", + "xml2js": "^0.5.0 || ^0.6.2" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/minio/node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/minio/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minio/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemailer": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pdfkit": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz", + "integrity": "sha512-1rRXK6x5o8I/3dBrBzXfxibpHpkfCnIA7EBAES7pEpGFc/65inMLlA8SalGWpJfal7BGekxeLf6A30IOpQpc5Q==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0", + "fflate": "^0.8.3", + "fontkit": "^2.0.4", + "linebreak": "^1.1.0", + "png-js": "^2.0.0" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/png-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", + "integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==", + "dependencies": { + "fflate": "^0.8.2" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz", + "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/yqnn" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wasm-feature-detect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", + "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zip-stream": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz", + "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==", + "license": "MIT", + "dependencies": { + "compress-commons": "^7.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6155359 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "daymark", + "private": true, + "version": "1.18.1", + "type": "module", + "scripts": { + "dev": "vite", + "dev:server": "node server.mjs", + "start": "node server.mjs", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", + "@vitejs/plugin-react": "latest", + "archiver": "^8.0.0", + "exifr": "^7.1.3", + "express": "^5.1.0", + "express-rate-limit": "^8.6.2", + "leaflet": "^1.9.4", + "lucide-react": "^0.468.0", + "minio": "^8.0.7", + "multer": "^2.2.0", + "nodemailer": "^9.0.5", + "pdfkit": "^0.20.1", + "pg": "^8.16.3", + "qrcode": "^1.5.4", + "react": "latest", + "react-dom": "latest", + "react-leaflet": "^5.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "sharp": "^0.35.3", + "tesseract.js": "^7.0.0", + "vite": "latest" + } +} diff --git a/public/field-log-icon.svg b/public/field-log-icon.svg new file mode 100644 index 0000000..7570ba9 --- /dev/null +++ b/public/field-log-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..08e9570 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,28 @@ +{ + "name": "Daymark", + "short_name": "Daymark", + "description": "Private self-hosted notes, diary entries, and media.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#1a1b26", + "theme_color": "#1a1b26", + "icons": [ + { + "src": "/field-log-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { "name": "New entry", "short_name": "New", "url": "/?new=1" }, + { "name": "Gallery", "short_name": "Gallery", "url": "/?view=gallery" }, + { "name": "Map", "short_name": "Map", "url": "/?view=map" } + ], + "share_target": { + "action": "/?share=1", + "method": "GET", + "params": { "title": "title", "text": "text", "url": "url" } + } +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..87e651d --- /dev/null +++ b/public/sw.js @@ -0,0 +1,39 @@ +const CACHE = "field-log-shell-v1"; +const SHELL = ["/", "/manifest.webmanifest", "/field-log-icon.svg"]; +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); + self.skipWaiting(); +}); +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)), + ), + ), + ); + self.clients.claim(); +}); +self.addEventListener("fetch", (event) => { + const request = event.request; + if ( + request.method !== "GET" || + new URL(request.url).pathname.startsWith("/api/") + ) + return; + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) + caches + .open(CACHE) + .then((cache) => cache.put(request, response.clone())); + return response; + }) + .catch(() => + caches.match(request).then((cached) => cached || caches.match("/")), + ), + ); +}); diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..269b473 --- /dev/null +++ b/server.mjs @@ -0,0 +1,4494 @@ +import express from "express"; +import pg from "pg"; +import path from "node:path"; +import crypto from "node:crypto"; +import multer from "multer"; +import { Client as MinioClient } from "minio"; +import { ZipArchive } from "archiver"; +import PDFDocument from "pdfkit"; +import sharp from "sharp"; +import nodemailer from "nodemailer"; +import QRCode from "qrcode"; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; +import { rateLimit } from "express-rate-limit"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import os from "node:os"; +import { execFile } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { mkdir, readdir, stat, unlink } from "node:fs/promises"; +const { Pool } = pg, + app = express(), + port = +(process.env.PORT || 3000), + root = path.dirname(fileURLToPath(import.meta.url)), + scrypt = promisify(crypto.scrypt), + runFile = promisify(execFile), + backupPath = process.env.BACKUP_PATH || "/backups"; +let backupRunning = false; +const mailer = nodemailer.createTransport({ + host: process.env.SMTP_HOST || "field-log-mail", + port: +(process.env.SMTP_PORT || 1025), + secure: false, +}); +function privateAddress(address) { + if (address.toLowerCase().startsWith("::ffff:")) + return privateAddress(address.slice(7)); + return ( + address === "::" || + address === "::1" || + address.startsWith("fe80:") || + address.startsWith("fc") || + address.startsWith("fd") || + /^127\./.test(address) || + /^10\./.test(address) || + /^192\.168\./.test(address) || + /^169\.254\./.test(address) || + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(address) || + /^198\.(1[89])\./.test(address) || + /^172\.(1[6-9]|2\d|3[01])\./.test(address) || + /^0\./.test(address) + ); +} +async function safeRemoteUrl(value) { + const url = new URL(value); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) + throw new Error("Only public HTTP(S) links are supported"); + if ( + ["localhost", "localhost.localdomain"].includes(url.hostname.toLowerCase()) + ) + throw new Error("Private network links are not supported"); + const addresses = isIP(url.hostname) + ? [{ address: url.hostname }] + : await lookup(url.hostname, { all: true }); + if ( + !addresses.length || + addresses.some((item) => privateAddress(item.address)) + ) + throw new Error("Private network links are not supported"); + return url; +} +function htmlAttribute(tag, name) { + return tag.match(new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i"))?.[1]; +} +async function remotePreview(value, redirects = 0) { + const url = await safeRemoteUrl(value), + response = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(8000), + headers: { + "User-Agent": "Daymark-Link-Preview/1.0", + Accept: "text/html,application/xhtml+xml", + }, + }); + if ( + response.status >= 300 && + response.status < 400 && + response.headers.get("location") + ) { + if (redirects >= 3) throw new Error("Too many redirects"); + return remotePreview( + new URL(response.headers.get("location"), url).href, + redirects + 1, + ); + } + if ( + !response.ok || + !response.headers.get("content-type")?.includes("text/html") + ) + throw new Error("Link does not provide an HTML preview"); + const reader = response.body.getReader(); + let size = 0, + html = ""; + while (size < 524288) { + const { done, value: chunk } = await reader.read(); + if (done) break; + size += chunk.length; + html += new TextDecoder().decode(chunk, { stream: true }); + } + reader.cancel().catch(() => {}); + const metadata = {}; + for (const tag of html.match(/]*>/gi) || []) { + const key = htmlAttribute(tag, "property") || htmlAttribute(tag, "name"), + content = htmlAttribute(tag, "content"); + if (key && content) metadata[key.toLowerCase()] = content; + } + const title = + metadata["og:title"] || + metadata["twitter:title"] || + html.match(/]*>([^<]*)<\/title>/i)?.[1] || + url.hostname; + return { + url: url.href, + title: title.trim().slice(0, 300), + description: String( + metadata["og:description"] || + metadata.description || + metadata["twitter:description"] || + "", + ) + .trim() + .slice(0, 600), + image: metadata["og:image"] + ? new URL(metadata["og:image"], url).href + : null, + siteName: metadata["og:site_name"] || url.hostname, + }; +} +if (!process.env.DATABASE_URL && !process.env.DB_HOST) + process.exit(console.error("DATABASE_URL or DB_HOST is required")); +const pool = new Pool({ + ...(process.env.DATABASE_URL + ? { connectionString: process.env.DATABASE_URL } + : { + host: process.env.DB_HOST, + port: +(process.env.DB_PORT || 5432), + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + }), + ssl: + process.env.DATABASE_SSL === "true" ? { rejectUnauthorized: false } : false, + max: +(process.env.DATABASE_POOL_SIZE || 10), +}); +const mediaBucket = process.env.MINIO_BUCKET || "field-log-media"; +const storageQuota = +(process.env.STORAGE_QUOTA || 5 * 1024 * 1024 * 1024); +const mediaStore = new MinioClient({ + endPoint: process.env.MINIO_HOST || "field-log-minio", + port: +(process.env.MINIO_PORT || 9000), + useSSL: process.env.MINIO_SSL === "true", + accessKey: process.env.MINIO_ACCESS_KEY || "fieldlog", + secretKey: process.env.MINIO_SECRET_KEY || "change-this-minio-password", +}); +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: +(process.env.MEDIA_FILE_LIMIT || 50 * 1024 * 1024), + files: 12, + }, +}); +async function storageUsed(userId) { + let total = 0; + for await (const object of mediaStore.listObjectsV2( + mediaBucket, + `${userId}/`, + true, + )) + total += object.size || 0; + return total; +} +async function userStorageQuota(userId, executor = pool) { + const { rows } = await executor.query( + "SELECT COALESCE(storage_quota,$2)::bigint AS quota FROM diary_users WHERE id=$1", + [userId, storageQuota], + ); + return rows[0] ? +rows[0].quota : storageQuota; +} +async function migrate() { + await pool.query( + `CREATE TABLE IF NOT EXISTS diary_users(id BIGSERIAL PRIMARY KEY,username TEXT UNIQUE NOT NULL,email TEXT UNIQUE NOT NULL,password_hash TEXT NOT NULL,display_name TEXT NOT NULL,bio TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ DEFAULT NOW());CREATE TABLE IF NOT EXISTS diary_sessions(token_hash TEXT PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,expires_at TIMESTAMPTZ NOT NULL);CREATE TABLE IF NOT EXISTS diary_entries(id BIGSERIAL PRIMARY KEY,entry_date DATE NOT NULL DEFAULT CURRENT_DATE,title TEXT NOT NULL DEFAULT 'Untitled',body JSONB NOT NULL DEFAULT '[]',tags JSONB NOT NULL DEFAULT '[]',media JSONB NOT NULL DEFAULT '[]',link TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ DEFAULT NOW(),updated_at TIMESTAMPTZ DEFAULT NOW());ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES diary_users(id) ON DELETE CASCADE;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'private';ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS mood TEXT NOT NULL DEFAULT '';ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS location_name TEXT NOT NULL DEFAULT '';ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS pinned BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;CREATE TABLE IF NOT EXISTS diary_entry_revisions(id BIGSERIAL PRIMARY KEY,entry_id BIGINT NOT NULL,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,snapshot JSONB NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_entries_user_date ON diary_entries(user_id,entry_date DESC);CREATE INDEX IF NOT EXISTS diary_sessions_expiry ON diary_sessions(expires_at);CREATE INDEX IF NOT EXISTS diary_revisions_entry ON diary_entry_revisions(entry_id,created_at DESC);`, + ); + await pool.query( + `CREATE INDEX IF NOT EXISTS diary_entries_search ON diary_entries USING GIN ((to_tsvector('english'::regconfig,coalesce(title,'')||' '||coalesce(mood,'')||' '||coalesce(location_name,'')) || jsonb_to_tsvector('english'::regconfig,body,'["string"]'::jsonb) || jsonb_to_tsvector('english'::regconfig,tags,'["string"]'::jsonb)))`, + ); + await pool.query( + `CREATE TABLE IF NOT EXISTS diary_journals(id BIGSERIAL PRIMARY KEY,owner_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,name TEXT NOT NULL,description TEXT NOT NULL DEFAULT '',color TEXT NOT NULL DEFAULT '#7aa2f7',visibility TEXT NOT NULL DEFAULT 'private',sort_order INTEGER NOT NULL DEFAULT 0,is_default BOOLEAN NOT NULL DEFAULT false,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),UNIQUE(owner_id,name));ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS journal_id BIGINT REFERENCES diary_journals(id) ON DELETE RESTRICT;INSERT INTO diary_journals(owner_id,name,is_default) SELECT u.id,'Field Notes',true FROM diary_users u WHERE NOT EXISTS(SELECT 1 FROM diary_journals j WHERE j.owner_id=u.id);UPDATE diary_entries e SET journal_id=(SELECT j.id FROM diary_journals j WHERE j.owner_id=e.user_id ORDER BY j.is_default DESC,j.sort_order,j.id LIMIT 1) WHERE e.journal_id IS NULL;CREATE INDEX IF NOT EXISTS diary_entries_journal_date ON diary_entries(journal_id,entry_date DESC);CREATE INDEX IF NOT EXISTS diary_journals_owner_sort ON diary_journals(owner_id,sort_order,id);CREATE UNIQUE INDEX IF NOT EXISTS diary_journals_one_default ON diary_journals(owner_id) WHERE is_default;`, + ); + await pool.query( + `CREATE TABLE IF NOT EXISTS diary_journal_members(journal_id BIGINT NOT NULL REFERENCES diary_journals(id) ON DELETE CASCADE,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,role TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),PRIMARY KEY(journal_id,user_id));CREATE TABLE IF NOT EXISTS diary_journal_invitations(id BIGSERIAL PRIMARY KEY,journal_id BIGINT NOT NULL REFERENCES diary_journals(id) ON DELETE CASCADE,invited_by BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,email TEXT NOT NULL DEFAULT '',role TEXT NOT NULL,token_hash TEXT UNIQUE NOT NULL,expires_at TIMESTAMPTZ NOT NULL,accepted_at TIMESTAMPTZ,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_members_user ON diary_journal_members(user_id,journal_id);CREATE INDEX IF NOT EXISTS diary_invitations_journal ON diary_journal_invitations(journal_id,created_at DESC);`, + ); + await pool.query( + `CREATE TABLE IF NOT EXISTS diary_comments(id BIGSERIAL PRIMARY KEY,entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,parent_id BIGINT REFERENCES diary_comments(id) ON DELETE CASCADE,body TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE TABLE IF NOT EXISTS diary_reactions(id BIGSERIAL PRIMARY KEY,entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,comment_id BIGINT REFERENCES diary_comments(id) ON DELETE CASCADE,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,emoji TEXT NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE TABLE IF NOT EXISTS diary_activity(id BIGSERIAL PRIMARY KEY,journal_id BIGINT NOT NULL REFERENCES diary_journals(id) ON DELETE CASCADE,actor_id BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,entry_id BIGINT REFERENCES diary_entries(id) ON DELETE CASCADE,action TEXT NOT NULL,metadata JSONB NOT NULL DEFAULT '{}',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE TABLE IF NOT EXISTS diary_notifications(id BIGSERIAL PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,actor_id BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,entry_id BIGINT REFERENCES diary_entries(id) ON DELETE CASCADE,comment_id BIGINT REFERENCES diary_comments(id) ON DELETE CASCADE,type TEXT NOT NULL,read_at TIMESTAMPTZ,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_comments_entry ON diary_comments(entry_id,created_at);CREATE INDEX IF NOT EXISTS diary_reactions_entry ON diary_reactions(entry_id);CREATE UNIQUE INDEX IF NOT EXISTS diary_reactions_unique ON diary_reactions(entry_id,COALESCE(comment_id,0),user_id,emoji);CREATE INDEX IF NOT EXISTS diary_activity_journal ON diary_activity(journal_id,created_at DESC);CREATE INDEX IF NOT EXISTS diary_notifications_user ON diary_notifications(user_id,read_at,created_at DESC);`, + ); + await pool.query( + `CREATE TABLE IF NOT EXISTS diary_public_links(id BIGSERIAL PRIMARY KEY,entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,token_hash TEXT UNIQUE NOT NULL,password_hash TEXT,expires_at TIMESTAMPTZ NOT NULL,revoked_at TIMESTAMPTZ,view_count BIGINT NOT NULL DEFAULT 0,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),last_viewed_at TIMESTAMPTZ);CREATE TABLE IF NOT EXISTS diary_public_sessions(token_hash TEXT PRIMARY KEY,link_id BIGINT NOT NULL REFERENCES diary_public_links(id) ON DELETE CASCADE,expires_at TIMESTAMPTZ NOT NULL);CREATE INDEX IF NOT EXISTS diary_public_links_entry ON diary_public_links(entry_id,created_at DESC);CREATE INDEX IF NOT EXISTS diary_public_sessions_expiry ON diary_public_sessions(expires_at);`, + ); + await pool.query( + `ALTER TABLE diary_public_links ADD COLUMN IF NOT EXISTS token_ciphertext TEXT;`, + ); + await pool.query( + `CREATE TABLE IF NOT EXISTS diary_entry_links(id BIGSERIAL PRIMARY KEY,source_entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,target_entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,label TEXT NOT NULL DEFAULT 'related',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),UNIQUE(source_entry_id,target_entry_id),CHECK(source_entry_id<>target_entry_id));CREATE TABLE IF NOT EXISTS diary_people(id BIGSERIAL PRIMARY KEY,owner_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,name TEXT NOT NULL,notes TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),UNIQUE(owner_id,name));CREATE TABLE IF NOT EXISTS diary_entry_people(entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,person_id BIGINT NOT NULL REFERENCES diary_people(id) ON DELETE CASCADE,created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,PRIMARY KEY(entry_id,person_id));CREATE TABLE IF NOT EXISTS diary_collections(id BIGSERIAL PRIMARY KEY,journal_id BIGINT NOT NULL REFERENCES diary_journals(id) ON DELETE CASCADE,name TEXT NOT NULL,color TEXT NOT NULL DEFAULT '#bb9af7',description TEXT NOT NULL DEFAULT '',created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),UNIQUE(journal_id,name));CREATE TABLE IF NOT EXISTS diary_collection_entries(collection_id BIGINT NOT NULL REFERENCES diary_collections(id) ON DELETE CASCADE,entry_id BIGINT NOT NULL REFERENCES diary_entries(id) ON DELETE CASCADE,added_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,PRIMARY KEY(collection_id,entry_id));CREATE INDEX IF NOT EXISTS diary_links_source ON diary_entry_links(source_entry_id);CREATE INDEX IF NOT EXISTS diary_links_target ON diary_entry_links(target_entry_id);CREATE INDEX IF NOT EXISTS diary_entry_people_person ON diary_entry_people(person_id);CREATE INDEX IF NOT EXISTS diary_collection_entries_entry ON diary_collection_entries(entry_id);`, + ); + await pool.query( + `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;CREATE TABLE IF NOT EXISTS diary_account_tokens(id BIGSERIAL PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,type TEXT NOT NULL,token_hash TEXT UNIQUE NOT NULL,expires_at TIMESTAMPTZ NOT NULL,used_at TIMESTAMPTZ,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_account_tokens_user ON diary_account_tokens(user_id,type,created_at DESC);`, + ); + await pool.query( + `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS totp_secret TEXT;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS recovery_code_hashes JSONB NOT NULL DEFAULT '[]';CREATE TABLE IF NOT EXISTS diary_auth_challenges(token_hash TEXT PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,type TEXT NOT NULL,payload JSONB NOT NULL DEFAULT '{}',expires_at TIMESTAMPTZ NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_auth_challenges_user ON diary_auth_challenges(user_id,type,expires_at);`, + ); + await pool.query( + `CREATE TABLE IF NOT EXISTS diary_passkeys(id BIGSERIAL PRIMARY KEY,user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,credential_id TEXT UNIQUE NOT NULL,public_key BYTEA NOT NULL,counter BIGINT NOT NULL DEFAULT 0,transports JSONB NOT NULL DEFAULT '[]',device_type TEXT NOT NULL DEFAULT '',backed_up BOOLEAN NOT NULL DEFAULT false,name TEXT NOT NULL DEFAULT 'Passkey',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),last_used_at TIMESTAMPTZ);CREATE INDEX IF NOT EXISTS diary_passkeys_user ON diary_passkeys(user_id,created_at);`, + ); + await pool.query( + `ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS id UUID DEFAULT gen_random_uuid();ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW();ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS ip_address TEXT NOT NULL DEFAULT '';ALTER TABLE diary_sessions ADD COLUMN IF NOT EXISTS user_agent TEXT NOT NULL DEFAULT '';CREATE UNIQUE INDEX IF NOT EXISTS diary_sessions_id ON diary_sessions(id);CREATE TABLE IF NOT EXISTS diary_login_history(id BIGSERIAL PRIMARY KEY,user_id BIGINT REFERENCES diary_users(id) ON DELETE CASCADE,identifier TEXT NOT NULL DEFAULT '',success BOOLEAN NOT NULL,method TEXT NOT NULL,ip_address TEXT NOT NULL DEFAULT '',user_agent TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_login_history_user ON diary_login_history(user_id,created_at DESC);`, + ); + await pool.query( + `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ;CREATE TABLE IF NOT EXISTS diary_site_settings(id BOOLEAN PRIMARY KEY DEFAULT true CHECK(id),registration_mode TEXT NOT NULL DEFAULT 'open',updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());INSERT INTO diary_site_settings(id) VALUES(true) ON CONFLICT(id) DO NOTHING;CREATE TABLE IF NOT EXISTS diary_registration_invites(id BIGSERIAL PRIMARY KEY,code_hash TEXT UNIQUE NOT NULL,created_by BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,max_uses INTEGER NOT NULL DEFAULT 1,use_count INTEGER NOT NULL DEFAULT 0,expires_at TIMESTAMPTZ NOT NULL,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_registration_invites_active ON diary_registration_invites(expires_at,use_count);`, + ); + await pool.query( + `ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS is_encrypted BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS encrypted_payload JSONB;`, + ); + await pool.query( + `ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS reminder_at TIMESTAMPTZ;`, + ); + await pool.query( + `ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS weather JSONB;`, + ); + await pool.query( + `ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS site_name TEXT NOT NULL DEFAULT 'Daymark';ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS site_tagline TEXT NOT NULL DEFAULT 'A private record of days, places, people, and ideas.';ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS site_notice TEXT NOT NULL DEFAULT 'NOTES FROM HOME AND AWAY';ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS footer_text TEXT NOT NULL DEFAULT 'WORDS, IMAGES & OTHER EVIDENCE';`, + ); + await pool.query( + `CREATE TABLE IF NOT EXISTS diary_admin_audit(id BIGSERIAL PRIMARY KEY,admin_id BIGINT REFERENCES diary_users(id) ON DELETE SET NULL,action TEXT NOT NULL,target_type TEXT NOT NULL DEFAULT '',target_id TEXT NOT NULL DEFAULT '',details JSONB NOT NULL DEFAULT '{}',ip_address TEXT NOT NULL DEFAULT '',created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_admin_audit_created ON diary_admin_audit(created_at DESC);CREATE INDEX IF NOT EXISTS diary_admin_audit_admin ON diary_admin_audit(admin_id,created_at DESC);`, + ); + await pool.query( + `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS storage_quota BIGINT;`, + ); + await pool.query( + `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS preferences JSONB NOT NULL DEFAULT '{}';`, + ); + await pool.query( + `ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_memory_percent INTEGER NOT NULL DEFAULT 90;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_db_connections_percent INTEGER NOT NULL DEFAULT 80;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_failed_logins INTEGER NOT NULL DEFAULT 10;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_backup_hours INTEGER NOT NULL DEFAULT 30;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS alert_storage_percent INTEGER NOT NULL DEFAULT 90;`, + ); + await pool.query( + `ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS maintenance_enabled BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS maintenance_starts_at TIMESTAMPTZ;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS maintenance_ends_at TIMESTAMPTZ;ALTER TABLE diary_site_settings ADD COLUMN IF NOT EXISTS maintenance_message TEXT NOT NULL DEFAULT 'The diary is temporarily unavailable while maintenance is completed.';`, + ); + await pool.query( + `ALTER TABLE diary_entries ADD COLUMN IF NOT EXISTS entry_time TIME;UPDATE diary_entries SET entry_time=created_at::time WHERE entry_time IS NULL;`, + ); + await pool.query( + `ALTER TABLE diary_comments ADD COLUMN IF NOT EXISTS approved BOOLEAN NOT NULL DEFAULT true;CREATE TABLE IF NOT EXISTS diary_user_blocks(blocker_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,blocked_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),PRIMARY KEY(blocker_id,blocked_id),CHECK(blocker_id<>blocked_id));CREATE INDEX IF NOT EXISTS diary_user_blocks_blocked ON diary_user_blocks(blocked_id,blocker_id);`, + ); + await pool.query( + `ALTER TABLE diary_comments ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;CREATE INDEX IF NOT EXISTS diary_comments_deleted ON diary_comments(entry_id,deleted_at);`, + ); + await pool.query( + `ALTER TABLE diary_notifications ADD COLUMN IF NOT EXISTS email_sent_at TIMESTAMPTZ;ALTER TABLE diary_notifications ADD COLUMN IF NOT EXISTS email_attempted_at TIMESTAMPTZ;CREATE INDEX IF NOT EXISTS diary_notifications_email_pending ON diary_notifications(user_id,created_at) WHERE email_sent_at IS NULL;`, + ); + await pool.query( + `CREATE TABLE IF NOT EXISTS diary_user_backups(id UUID PRIMARY KEY DEFAULT gen_random_uuid(),user_id BIGINT NOT NULL REFERENCES diary_users(id) ON DELETE CASCADE,file_name TEXT NOT NULL,bytes BIGINT NOT NULL DEFAULT 0,created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());CREATE INDEX IF NOT EXISTS diary_user_backups_user ON diary_user_backups(user_id,created_at DESC);`, + ); + await pool.query( + `ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS self_deactivated BOOLEAN NOT NULL DEFAULT false;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS deletion_requested_at TIMESTAMPTZ;ALTER TABLE diary_users ADD COLUMN IF NOT EXISTS deletion_scheduled_for TIMESTAMPTZ;`, + ); + if (process.env.ADMIN_EMAIL) + await pool.query( + "UPDATE diary_users SET is_admin=true WHERE email=lower($1)", + [process.env.ADMIN_EMAIL], + ); + if (!(await mediaStore.bucketExists(mediaBucket))) + await mediaStore.makeBucket(mediaBucket); +} +const hash = (s) => crypto.createHash("sha256").update(s).digest("hex"); +async function password(p, salt = crypto.randomBytes(16).toString("hex")) { + return salt + ":" + Buffer.from(await scrypt(p, salt, 64)).toString("hex"); +} +async function verify(p, v) { + const [salt, key] = v.split(":"); + return crypto.timingSafeEqual( + Buffer.from(key, "hex"), + Buffer.from((await password(p, salt)).split(":")[1], "hex"), + ); +} +const base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +function base32Encode(buffer) { + let bits = ""; + for (const byte of buffer) bits += byte.toString(2).padStart(8, "0"); + return bits + .match(/.{1,5}/g) + .map((chunk) => base32Alphabet[parseInt(chunk.padEnd(5, "0"), 2)]) + .join(""); +} +function base32Decode(value) { + const bits = [...value.toUpperCase().replace(/=+$/, "")] + .map((character) => + base32Alphabet.indexOf(character).toString(2).padStart(5, "0"), + ) + .join(""); + return Buffer.from( + (bits.match(/.{8}/g) || []).map((byte) => parseInt(byte, 2)), + ); +} +function totpCode(secret, offset = 0) { + const counter = Math.floor(Date.now() / 30000) + offset, + buffer = Buffer.alloc(8); + buffer.writeBigUInt64BE(BigInt(counter)); + const digest = crypto + .createHmac("sha1", base32Decode(secret)) + .update(buffer) + .digest(), + position = digest[digest.length - 1] & 15, + number = (digest.readUInt32BE(position) & 0x7fffffff) % 1000000; + return String(number).padStart(6, "0"); +} +function verifyTotp(secret, code) { + const candidate = String(code || "").trim(); + return ( + /^\d{6}$/.test(candidate) && + [-1, 0, 1].some((offset) => + crypto.timingSafeEqual( + Buffer.from(totpCode(secret, offset)), + Buffer.from(candidate), + ), + ) + ); +} +function encryptSecret(value) { + const key = crypto + .createHash("sha256") + .update(process.env.AUTH_ENCRYPTION_KEY || "field-log-change-this-key") + .digest(), + iv = crypto.randomBytes(12), + cipher = crypto.createCipheriv("aes-256-gcm", key, iv), + encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return [iv, cipher.getAuthTag(), encrypted] + .map((part) => part.toString("base64url")) + .join("."); +} +function decryptSecret(value) { + const [iv, tag, encrypted] = value + .split(".") + .map((part) => Buffer.from(part, "base64url")), + key = crypto + .createHash("sha256") + .update(process.env.AUTH_ENCRYPTION_KEY || "field-log-change-this-key") + .digest(), + decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString( + "utf8", + ); +} +async function authChallenge(userId, type, payload = {}, minutes = 10) { + const token = crypto.randomBytes(32).toString("base64url"); + await pool.query( + "INSERT INTO diary_auth_challenges(token_hash,user_id,type,payload,expires_at) VALUES($1,$2,$3,$4,NOW()+($5||' minutes')::interval)", + [hash(token), userId, type, payload, minutes], + ); + return token; +} +function cookie(req, name) { + return req.headers.cookie + ?.split(";") + .map((x) => x.trim().split("=")) + .find((x) => x[0] === name)?.[1]; +} +async function auth(req, res, next) { + try { + const token = cookie(req, "diary_session"); + if (!token) + return res.status(401).json({ error: "Authentication required" }); + const { rows } = await pool.query( + `SELECT u.id,u.username,u.email,u.display_name,u.bio,u.email_verified_at,u.two_factor_enabled,u.is_admin FROM diary_sessions s JOIN diary_users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>NOW() AND u.disabled_at IS NULL`, + [hash(token)], + ); + if (!rows[0]) return res.status(401).json({ error: "Session expired" }); + req.user = rows[0]; + await pool.query( + "UPDATE diary_sessions SET last_seen_at=NOW() WHERE token_hash=$1 AND last_seen_at ({ + id: +r.id, + date: + r.entry_date instanceof Date + ? r.entry_date.toISOString().slice(0, 10) + : String(r.entry_date).slice(0, 10), + time: r.entry_time ? String(r.entry_time).slice(0, 5) : "", + title: r.title, + body: r.body, + tags: r.tags, + media: r.media, + link: r.link, + visibility: r.visibility, + mood: r.mood, + location: r.location_name, + latitude: r.latitude, + longitude: r.longitude, + pinned: r.pinned, + author: r.display_name, + userId: r.user_id ? +r.user_id : null, + owner: r.owner ?? true, + journalId: r.journal_id ? +r.journal_id : null, + encrypted: r.is_encrypted || false, + encryptedPayload: r.encrypted_payload || null, + reminderAt: r.reminder_at || null, + weather: r.weather || null, +}); +const journal = (row) => ({ + id: +row.id, + name: row.name, + description: row.description, + color: row.color, + visibility: row.visibility, + sortOrder: row.sort_order, + isDefault: row.is_default, + ownerId: +row.owner_id, + entryCount: +(row.entry_count || 0), + role: row.role || "owner", + owned: row.role ? row.role === "owner" : true, +}); +async function writableJournal( + userId, + requestedId, + roles = ["owner", "editor", "contributor"], +) { + const { rows } = await pool.query( + `SELECT j.id,CASE WHEN j.owner_id=$1 THEN 'owner' ELSE m.role END AS role FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE (j.owner_id=$1 OR m.user_id=$1) AND ($2::bigint IS NULL OR j.id=$2) ORDER BY CASE WHEN j.id=$2 THEN 0 ELSE 1 END,j.is_default DESC,j.sort_order,j.id LIMIT 1`, + [userId, requestedId || null], + ); + return rows[0] && roles.includes(rows[0].role) ? rows[0] : null; +} +async function entryAccess(userId, entryId) { + const { rows } = await pool.query( + `SELECT e.id,e.journal_id,e.user_id,e.title,e.is_encrypted,CASE WHEN j.owner_id=$1 THEN 'owner' ELSE m.role END AS role,(e.user_id=$1 OR j.owner_id=$1 OR m.role='editor') AS can_manage FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE e.id=$2 AND e.deleted_at IS NULL AND (e.user_id=$1 OR j.owner_id=$1 OR m.user_id=$1 OR e.visibility='public')`, + [userId, entryId], + ); + return rows[0] || null; +} +async function activity( + journalId, + actorId, + action, + entryId = null, + metadata = {}, +) { + await pool.query( + "INSERT INTO diary_activity(journal_id,actor_id,entry_id,action,metadata) VALUES($1,$2,$3,$4,$5)", + [journalId, actorId, entryId, action, metadata], + ); +} +async function notifyJournalMembers(journalId, actorId, entryId, type) { + await pool.query( + `INSERT INTO diary_notifications(user_id,actor_id,entry_id,type) SELECT recipients.user_id,$2,$3,$4 FROM (SELECT owner_id AS user_id FROM diary_journals WHERE id=$1 UNION SELECT user_id FROM diary_journal_members WHERE journal_id=$1) recipients JOIN diary_users u ON u.id=recipients.user_id WHERE recipients.user_id<>$2 AND COALESCE((u.preferences->>'notifyShares')::boolean,true) AND NOT EXISTS(SELECT 1 FROM diary_user_blocks b WHERE (b.blocker_id=recipients.user_id AND b.blocked_id=$2) OR (b.blocker_id=$2 AND b.blocked_id=recipients.user_id))`, + [journalId, actorId, entryId, type], + ); +} +async function sendAccountToken(req, user, type) { + const token = crypto.randomBytes(32).toString("base64url"), + hours = type === "verify" ? 48 : 1, + route = type === "verify" ? "verify" : "reset", + origin = process.env.APP_URL || `${req.protocol}://${req.get("host")}`; + await pool.query( + "UPDATE diary_account_tokens SET used_at=NOW() WHERE user_id=$1 AND type=$2 AND used_at IS NULL", + [user.id, type], + ); + await pool.query( + "INSERT INTO diary_account_tokens(user_id,type,token_hash,expires_at) VALUES($1,$2,$3,NOW()+($4||' hours')::interval)", + [user.id, type, hash(token), hours], + ); + await mailer.sendMail({ + from: process.env.MAIL_FROM || "Daymark ", + to: user.email, + subject: + type === "verify" + ? "Verify your Daymark email" + : "Reset your Daymark password", + text: `${type === "verify" ? "Verify your email" : "Reset your password"}: ${origin}/${route}/${token}\n\nThis link expires in ${hours} hour${hours === 1 ? "" : "s"}.`, + }); +} +function webauthnConfig(req) { + const origin = + process.env.WEBAUTHN_ORIGIN || `${req.protocol}://${req.get("host")}`; + return { + origin, + rpID: process.env.WEBAUTHN_RP_ID || new URL(origin).hostname, + rpName: process.env.WEBAUTHN_RP_NAME || "Daymark", + }; +} +let maintenanceCache = { expires: 0, value: null }; +async function maintenanceStatus() { + if (maintenanceCache.value && maintenanceCache.expires > Date.now()) + return maintenanceCache.value; + const { rows } = await pool.query( + `SELECT maintenance_enabled,maintenance_starts_at,maintenance_ends_at,maintenance_message,(maintenance_enabled AND (maintenance_starts_at IS NULL OR maintenance_starts_at<=NOW()) AND (maintenance_ends_at IS NULL OR maintenance_ends_at>NOW())) AS maintenance_active FROM diary_site_settings WHERE id=true`, + ); + maintenanceCache = { expires: Date.now() + 5000, value: rows[0] }; + return rows[0]; +} +app.disable("x-powered-by"); +app.set("trust proxy", 1); +app.use(express.json({ limit: process.env.JSON_LIMIT || "30mb" })); +const strictAuthLimit = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 20, + standardHeaders: "draft-8", + legacyHeaders: false, + message: { error: "Too many authentication attempts. Try again later." }, + }), + recoveryLimit = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 10, + standardHeaders: "draft-8", + legacyHeaders: false, + message: { error: "Too many recovery requests. Try again later." }, + }), + publicAccessLimit = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 60, + standardHeaders: "draft-8", + legacyHeaders: false, + message: { error: "Too many public-link attempts. Try again later." }, + }), + apiWriteLimit = rateLimit({ + windowMs: 60 * 1000, + limit: 180, + standardHeaders: "draft-8", + legacyHeaders: false, + message: { error: "Too many changes. Slow down and try again." }, + }); +app.use( + [ + "/api/auth/login", + "/api/auth/register", + "/api/auth/2fa/login", + "/api/auth/passkey/options", + "/api/auth/passkey/verify", + ], + strictAuthLimit, +); +app.use( + [ + "/api/auth/password/request", + "/api/auth/password/reset", + "/api/auth/verification/resend", + ], + recoveryLimit, +); +app.use("/api/public", publicAccessLimit); +app.use("/api", (req, res, next) => + ["GET", "HEAD", "OPTIONS"].includes(req.method) + ? next() + : apiWriteLimit(req, res, next), +); +app.use("/api", (req, res, next) => { + let csrf = cookie(req, "field_log_csrf"); + if (!csrf) { + csrf = crypto.randomBytes(24).toString("base64url"); + res.append( + "Set-Cookie", + `field_log_csrf=${csrf}; SameSite=Strict; Path=/; Max-Age=2592000${process.env.COOKIE_SECURE === "true" ? "; Secure" : ""}`, + ); + } + if ( + !["GET", "HEAD", "OPTIONS"].includes(req.method) && + cookie(req, "diary_session") + ) { + const supplied = String(req.get("x-csrf-token") || ""); + if ( + supplied.length !== csrf.length || + !crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(csrf)) + ) + return res.status(403).json({ error: "Invalid CSRF token" }); + } + next(); +}); +app.use("/api", async (req, res, next) => { + try { + if ( + req.path === "/health" || + req.path === "/site-settings" || + req.path.startsWith("/auth/") + ) + return next(); + const status = await maintenanceStatus(); + if (!status?.maintenance_active) return next(); + const token = cookie(req, "diary_session"); + if (token) { + const { rows } = await pool.query( + `SELECT u.is_admin FROM diary_sessions s JOIN diary_users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>NOW() AND u.disabled_at IS NULL`, + [hash(token)], + ); + if (rows[0]?.is_admin) return next(); + } + res.status(503).json({ + error: "Maintenance mode", + maintenance: true, + message: status.maintenance_message, + startsAt: status.maintenance_starts_at, + endsAt: status.maintenance_ends_at, + }); + } catch (error) { + next(error); + } +}); +app.get("/api/health", async (_q, r) => { + try { + await pool.query("SELECT 1"); + r.json({ status: "ok", database: "connected" }); + } catch { + r.status(503).json({ status: "error" }); + } +}); +app.get("/api/places", auth, async (req, res, next) => { + try { + const query = String(req.query.q || "") + .trim() + .slice(0, 160); + if (query.length < 3) + return res.status(400).json({ error: "Enter at least 3 characters" }); + const parameters = new URLSearchParams({ + q: query, + format: "jsonv2", + addressdetails: "1", + limit: "6", + }), + response = await fetch( + `https://nominatim.openstreetmap.org/search?${parameters}`, + { + headers: { + "User-Agent": `Daymark/1.0 (${process.env.APP_URL || "self-hosted"})`, + Accept: "application/json", + }, + }, + ); + if (!response.ok) + throw new Error(`Place provider returned ${response.status}`); + const places = await response.json(); + res.json( + places.map((place) => ({ + id: place.place_id, + name: place.display_name, + latitude: +place.lat, + longitude: +place.lon, + type: place.type, + })), + ); + } catch (error) { + next(error); + } +}); +app.get("/api/link-preview", auth, async (req, res) => { + try { + res.json(await remotePreview(String(req.query.url || ""))); + } catch (error) { + res + .status(422) + .json({ error: error.message || "Link preview unavailable" }); + } +}); +app.get("/api/weather", auth, async (req, res, next) => { + try { + const latitude = +req.query.latitude, + longitude = +req.query.longitude, + date = String(req.query.date || ""); + if ( + !Number.isFinite(latitude) || + !Number.isFinite(longitude) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 || + !/^\d{4}-\d{2}-\d{2}$/.test(date) + ) + return res + .status(400) + .json({ error: "Valid coordinates and date required" }); + const historical = + date < new Date(Date.now() - 5 * 86400000).toISOString().slice(0, 10), + endpoint = historical + ? "https://archive-api.open-meteo.com/v1/archive" + : "https://api.open-meteo.com/v1/forecast", + parameters = new URLSearchParams({ + latitude: String(latitude), + longitude: String(longitude), + start_date: date, + end_date: date, + daily: + "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max", + timezone: "auto", + }), + response = await fetch(`${endpoint}?${parameters}`), + result = await response.json(); + if (!response.ok || !result.daily?.time?.length) + return res.status(422).json({ + error: result.reason || "Weather is unavailable for this date", + }); + res.json({ + date: result.daily.time[0], + code: result.daily.weather_code[0], + temperatureMax: result.daily.temperature_2m_max[0], + temperatureMin: result.daily.temperature_2m_min[0], + precipitation: result.daily.precipitation_sum[0], + windMax: result.daily.wind_speed_10m_max[0], + timezone: result.timezone, + source: "Open-Meteo", + }); + } catch (error) { + next(error); + } +}); +app.get("/api/auth/registration", async (_req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT registration_mode FROM diary_site_settings WHERE id=true", + ); + res.json({ mode: rows[0].registration_mode }); + } catch (error) { + next(error); + } +}); +app.post("/api/auth/register", async (req, res, next) => { + const client = await pool.connect(); + let transactionOpen = false; + try { + const { username, email, password: pass, displayName } = req.body, + inviteCode = String(req.body.inviteCode || "") + .trim() + .toUpperCase(); + if ( + !/^[a-z0-9_-]{3,30}$/i.test(username) || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || + pass?.length < 10 + ) + return res.status(400).json({ + error: + "Use a valid username, email, and password of at least 10 characters", + }); + await client.query("BEGIN"); + transactionOpen = true; + const setting = await client.query( + "SELECT registration_mode FROM diary_site_settings WHERE id=true FOR UPDATE", + ), + mode = setting.rows[0].registration_mode; + if (mode === "closed") { + await client.query("ROLLBACK"); + transactionOpen = false; + return res + .status(403) + .json({ error: "Registration is currently closed" }); + } + if (mode === "invite") { + const accepted = await client.query( + "UPDATE diary_registration_invites SET use_count=use_count+1 WHERE code_hash=$1 AND expires_at>NOW() AND use_count { + try { + const { rows } = await pool.query( + "SELECT * FROM diary_users WHERE (username=lower($1) OR email=lower($1)) AND self_deactivated", + [req.body.login], + ); + if (!rows[0] || !(await verify(req.body.password || "", rows[0].password_hash))) + return res.status(401).json({ error: "Invalid credentials or account was disabled by an administrator" }); + await pool.query("UPDATE diary_users SET disabled_at=NULL,self_deactivated=false WHERE id=$1", [rows[0].id]); + await login(rows[0], req, res, "reactivation"); + const { password_hash, totp_secret, recovery_code_hashes, ...user } = rows[0]; + res.json({ ...user, disabled_at: null, self_deactivated: false }); + } catch (error) { next(error); } +}); +app.post("/api/auth/login", async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT * FROM diary_users WHERE username=lower($1) OR email=lower($1)", + [req.body.login], + ); + if ( + !rows[0] || + rows[0].disabled_at || + !(await verify(req.body.password || "", rows[0].password_hash)) + ) { + await recordLogin( + req, + rows[0]?.id || null, + String(req.body.login || ""), + false, + "password", + ); + if (rows[0]?.id) + await pool.query( + `INSERT INTO diary_notifications(user_id,type) SELECT id,'security_failed_login' FROM diary_users WHERE id=$1 AND COALESCE((preferences->>'notifySecurity')::boolean,true)`, + [rows[0].id], + ); + return res.status(401).json({ error: "Invalid credentials" }); + } + if (rows[0].two_factor_enabled) + return res.json({ + requiresTwoFactor: true, + challenge: await authChallenge(rows[0].id, "login-2fa"), + }); + await login(rows[0], req, res, "password"); + const { password_hash, totp_secret, recovery_code_hashes, ...user } = + rows[0]; + res.json(user); + } catch (e) { + next(e); + } +}); +app.post("/api/auth/2fa/login", async (req, res, next) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const challenge = await client.query( + "SELECT * FROM diary_auth_challenges WHERE token_hash=$1 AND type='login-2fa' AND expires_at>NOW() FOR UPDATE", + [hash(String(req.body.challenge || ""))], + ); + if (!challenge.rows[0]) { + await client.query("ROLLBACK"); + return res.status(401).json({ error: "Login challenge expired" }); + } + const userResult = await client.query( + "SELECT * FROM diary_users WHERE id=$1", + [challenge.rows[0].user_id], + ), + user = userResult.rows[0], + code = String(req.body.code || "") + .trim() + .toUpperCase(), + recoveryIndex = (user.recovery_code_hashes || []).findIndex( + (item) => item === hash(code), + ), + valid = + verifyTotp(decryptSecret(user.totp_secret), code) || recoveryIndex >= 0; + if (!valid) { + await client.query("ROLLBACK"); + return res.status(401).json({ error: "Invalid authentication code" }); + } + if (recoveryIndex >= 0) { + const remaining = [...user.recovery_code_hashes]; + remaining.splice(recoveryIndex, 1); + await client.query( + "UPDATE diary_users SET recovery_code_hashes=$1 WHERE id=$2", + [JSON.stringify(remaining), user.id], + ); + } + await client.query( + "DELETE FROM diary_auth_challenges WHERE token_hash=$1", + [hash(req.body.challenge)], + ); + await client.query("COMMIT"); + await login(user, req, res, recoveryIndex >= 0 ? "recovery-code" : "totp"); + const { password_hash, totp_secret, recovery_code_hashes, ...safeUser } = + user; + res.json(safeUser); + } catch (error) { + await client.query("ROLLBACK"); + next(error); + } finally { + client.release(); + } +}); +async function recordLogin(req, userId, identifier, success, method) { + await pool.query( + "INSERT INTO diary_login_history(user_id,identifier,success,method,ip_address,user_agent) VALUES($1,$2,$3,$4,$5,$6)", + [ + userId, + identifier.slice(0, 254), + success, + method, + req.ip || "", + String(req.get("user-agent") || "").slice(0, 500), + ], + ); +} +async function login(user, req, res, method = "password") { + const token = crypto.randomBytes(32).toString("base64url"), + ip = req.ip || "", + userAgent = String(req.get("user-agent") || "").slice(0, 500), + knownDevice = await pool.query( + "SELECT 1 FROM diary_sessions WHERE user_id=$1 AND ip_address=$2 AND user_agent=$3 LIMIT 1", + [user.id, ip, userAgent], + ); + await pool.query( + "INSERT INTO diary_sessions(token_hash,user_id,expires_at,ip_address,user_agent) VALUES($1,$2,NOW()+INTERVAL '30 days',$3,$4)", + [ + hash(token), + user.id, + ip, + userAgent, + ], + ); + await recordLogin( + req, + user.id, + user.email || user.username || "", + true, + method, + ); + if (method !== "registration" && !knownDevice.rowCount) + await pool.query( + `INSERT INTO diary_notifications(user_id,type) SELECT id,'security_new_device' FROM diary_users WHERE id=$1 AND COALESCE((preferences->>'notifySecurity')::boolean,true)`, + [user.id], + ); + res.setHeader( + "Set-Cookie", + `diary_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000${process.env.COOKIE_SECURE === "true" ? "; Secure" : ""}`, + ); +} +app.get("/api/auth/me", auth, (q, r) => r.json(q.user)); +const defaultUserPreferences = { + defaultVisibility: "private", + defaultJournal: "", + defaultView: "diary", + entriesPerPage: 20, + dateFormat: "locale", + timeFormat: "24h", + timezone: "auto", + temperatureUnit: "celsius", + distanceUnit: "metric", + weekStartsOn: "monday", + spellcheck: true, + autosave: true, + editorMode: "markdown", + imageQuality: "high", + mapZoom: 11, + rememberEntryOptions: true, + theme: "tokyo-night", + fontScale: 100, + density: "comfortable", + reduceMotion: false, + compactEntries: false, + accentColor: "#7aa2f7", + headerImage: "", + notifyComments: true, + notifyReplies: true, + notifyMentions: true, + notifyShares: true, + notifySecurity: true, + notificationInApp: true, + notificationBrowser: false, + notificationEmail: false, + digestFrequency: "instant", + digestTime: "08:00", + quietHoursStart: "", + quietHoursEnd: "", + locationPrecision: "exact", + commentPolicy: "members", + approveComments: false, + profileVisibility: "private", + shareExpiryDays: 7, + avatarUrl: "", + pronouns: "", + personalWebsite: "", + homeLocation: "", + language: "auto", + customTemplates: [], + customPrompts: [], + writingGoals: { weeklyEntries: 3, monthlyWords: 5000 }, + recurringPromptTime: "", + recurringPromptDays: [], +}; +function timeInZone(timezone) { + try { + return new Intl.DateTimeFormat("en-GB", { timeZone: timezone && timezone !== "auto" ? timezone : undefined, hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date()); + } catch { + return new Date().toTimeString().slice(0, 5); + } +} +function withinQuietHours(preferences) { + const start = preferences.quietHoursStart, end = preferences.quietHoursEnd; + if (!start || !end || start === end) return false; + const now = timeInZone(preferences.timezone); + return start < end ? now >= start && now < end : now >= start || now < end; +} +async function deliverNotificationEmails() { + const { rows } = await pool.query( + `SELECT n.id,n.user_id,n.type,n.created_at,u.email,u.display_name,u.preferences,a.display_name AS actor,e.title AS entry_title FROM diary_notifications n JOIN diary_users u ON u.id=n.user_id LEFT JOIN diary_users a ON a.id=n.actor_id LEFT JOIN diary_entries e ON e.id=n.entry_id WHERE u.email_verified_at IS NOT NULL AND n.email_sent_at IS NULL AND (n.email_attempted_at IS NULL OR n.email_attempted_at item.id); + await pool.query("UPDATE diary_notifications SET email_attempted_at=NOW() WHERE id=ANY($1::bigint[])", [ids]); + try { + const lines = group.items.map((item) => `${item.actor || "Diary security"} ${item.type.replaceAll("_", " ")}${item.entry_title ? `: ${item.entry_title}` : ""}`), digest = group.preferences.digestFrequency !== "instant" || lines.length > 1; + await mailer.sendMail({ + from: process.env.MAIL_FROM || "Daymark ", + to: group.user.email, + subject: digest ? `${lines.length} diary notifications` : lines[0], + text: `Hello ${group.user.display_name},\n\n${lines.map((line) => `- ${line}`).join("\n")}\n\nOpen your inbox: ${(process.env.APP_URL || "").replace(/\/$/, "")}/settings#notifications`, + }); + await pool.query("UPDATE diary_notifications SET email_sent_at=NOW() WHERE id=ANY($1::bigint[])", [ids]); + } catch (error) { + console.error("Notification email delivery failed", error.message); + } + } +} +app.get("/api/preferences", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT preferences FROM diary_users WHERE id=$1", + [req.user.id], + ); + res.json({ ...defaultUserPreferences, ...(rows[0]?.preferences || {}) }); + } catch (error) { + next(error); + } +}); +app.put("/api/preferences", auth, async (req, res, next) => { + try { + const clean = {}; + for (const [key, fallback] of Object.entries(defaultUserPreferences)) { + if (!(key in req.body)) continue; + const value = req.body[key]; + if (typeof fallback === "boolean") clean[key] = !!value; + else if (typeof fallback === "number") { + const numeric = Number(value); + if (Number.isFinite(numeric)) clean[key] = numeric; + } + else if (Array.isArray(fallback)) + clean[key] = Array.isArray(value) ? value.slice(0, 50) : []; + else if (fallback && typeof fallback === "object") + clean[key] = value && typeof value === "object" && !Array.isArray(value) ? value : fallback; + else clean[key] = String(value).slice(0, 500); + } + const { rows } = await pool.query( + "UPDATE diary_users SET preferences=preferences||$1::jsonb WHERE id=$2 RETURNING preferences", + [JSON.stringify(clean), req.user.id], + ); + res.json({ ...defaultUserPreferences, ...rows[0].preferences }); + } catch (error) { + next(error); + } +}); +app.put("/api/profile", auth, async (req, res, next) => { + try { + const displayName = String(req.body.displayName || "").trim().slice(0, 100); + const bio = String(req.body.bio || "").trim().slice(0, 2000); + if (displayName.length < 2) + return res.status(400).json({ error: "Display name must contain at least two characters" }); + const { rows } = await pool.query( + "UPDATE diary_users SET display_name=$1,bio=$2 WHERE id=$3 RETURNING id,username,email,display_name,bio,email_verified_at,two_factor_enabled,is_admin", + [displayName, bio, req.user.id], + ); + res.json(rows[0]); + } catch (error) { + next(error); + } +}); +app.post("/api/auth/logout", auth, async (q, r) => { + await pool.query("DELETE FROM diary_sessions WHERE token_hash=$1", [ + hash(cookie(q, "diary_session")), + ]); + r.setHeader( + "Set-Cookie", + "diary_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", + ); + r.status(204).end(); +}); +app.get("/api/security", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT two_factor_enabled,jsonb_array_length(recovery_code_hashes) AS recovery_codes FROM diary_users WHERE id=$1", + [req.user.id], + ), + passkeys = await pool.query( + "SELECT id,name,device_type,backed_up,created_at,last_used_at FROM diary_passkeys WHERE user_id=$1 ORDER BY created_at DESC", + [req.user.id], + ); + res.json({ + twoFactorEnabled: rows[0].two_factor_enabled, + recoveryCodes: +rows[0].recovery_codes, + passkeys: passkeys.rows.map((item) => ({ ...item, id: +item.id })), + }); + } catch (error) { + next(error); + } +}); +app.get("/api/security/sessions", auth, async (req, res, next) => { + try { + const currentHash = hash(cookie(req, "diary_session") || ""), + { rows } = await pool.query( + "SELECT id,created_at,last_seen_at,expires_at,ip_address,user_agent,(token_hash=$2) AS current FROM diary_sessions WHERE user_id=$1 AND expires_at>NOW() ORDER BY current DESC,last_seen_at DESC", + [req.user.id, currentHash], + ); + res.json(rows); + } catch (error) { + next(error); + } +}); +app.delete("/api/security/sessions/:id", auth, async (req, res, next) => { + try { + const currentHash = hash(cookie(req, "diary_session") || ""), + { rows } = await pool.query( + "DELETE FROM diary_sessions WHERE id=$1 AND user_id=$2 RETURNING token_hash", + [req.params.id, req.user.id], + ); + if (!rows[0]) return res.status(404).json({ error: "Session not found" }); + if (rows[0].token_hash === currentHash) + res.setHeader( + "Set-Cookie", + "diary_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0", + ); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.delete("/api/security/sessions", auth, async (req, res, next) => { + try { + const currentHash = hash(cookie(req, "diary_session") || ""); + await pool.query( + "DELETE FROM diary_sessions WHERE user_id=$1 AND token_hash<>$2", + [req.user.id, currentHash], + ); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.get("/api/security/login-history", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT id,success,method,ip_address,user_agent,created_at FROM diary_login_history WHERE user_id=$1 ORDER BY created_at DESC LIMIT 100", + [req.user.id], + ); + res.json(rows); + } catch (error) { + next(error); + } +}); +app.post("/api/security/passkeys/options", auth, async (req, res, next) => { + try { + const config = webauthnConfig(req), + existing = await pool.query( + "SELECT credential_id,transports FROM diary_passkeys WHERE user_id=$1", + [req.user.id], + ), + options = await generateRegistrationOptions({ + rpName: config.rpName, + rpID: config.rpID, + userName: req.user.email, + userDisplayName: req.user.display_name, + userID: Buffer.from(String(req.user.id)), + attestationType: "none", + excludeCredentials: existing.rows.map((item) => ({ + id: item.credential_id, + transports: item.transports, + })), + authenticatorSelection: { + residentKey: "preferred", + userVerification: "required", + }, + }), + challengeToken = await authChallenge(req.user.id, "passkey-register", { + challenge: options.challenge, + origin: config.origin, + rpID: config.rpID, + }); + res.json({ options, challengeToken }); + } catch (error) { + next(error); + } +}); +app.post("/api/security/passkeys/verify", auth, async (req, res, next) => { + try { + const challenge = await pool.query( + "SELECT * FROM diary_auth_challenges WHERE token_hash=$1 AND user_id=$2 AND type='passkey-register' AND expires_at>NOW()", + [hash(String(req.body.challengeToken || "")), req.user.id], + ); + if (!challenge.rows[0]) + return res.status(404).json({ error: "Passkey challenge expired" }); + const payload = challenge.rows[0].payload, + verification = await verifyRegistrationResponse({ + response: req.body.response, + expectedChallenge: payload.challenge, + expectedOrigin: payload.origin, + expectedRPID: payload.rpID, + }); + if (!verification.verified) + return res + .status(400) + .json({ error: "Passkey registration could not be verified" }); + const info = verification.registrationInfo, + credential = info.credential, + name = + String(req.body.name || "Passkey") + .trim() + .slice(0, 80) || "Passkey"; + await pool.query( + `INSERT INTO diary_passkeys(user_id,credential_id,public_key,counter,transports,device_type,backed_up,name) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, + [ + req.user.id, + credential.id, + Buffer.from(credential.publicKey), + credential.counter, + JSON.stringify(credential.transports || []), + info.credentialDeviceType, + info.credentialBackedUp, + name, + ], + ); + await pool.query("DELETE FROM diary_auth_challenges WHERE token_hash=$1", [ + hash(req.body.challengeToken), + ]); + res.status(201).json({ verified: true }); + } catch (error) { + if (error.code === "23505") + return res + .status(409) + .json({ error: "This passkey is already registered" }); + next(error); + } +}); +app.delete("/api/security/passkeys/:id", auth, async (req, res, next) => { + try { + const result = await pool.query( + "DELETE FROM diary_passkeys WHERE id=$1 AND user_id=$2", + [req.params.id, req.user.id], + ); + if (!result.rowCount) + return res.status(404).json({ error: "Passkey not found" }); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.post("/api/auth/passkey/options", async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT id FROM diary_users WHERE username=lower($1) OR email=lower($1)", + [String(req.body.login || "")], + ); + if (!rows[0]) + return res.status(404).json({ error: "No passkey account found" }); + const credentials = await pool.query( + "SELECT credential_id,transports FROM diary_passkeys WHERE user_id=$1", + [rows[0].id], + ); + if (!credentials.rowCount) + return res + .status(404) + .json({ error: "No passkeys registered for this account" }); + const config = webauthnConfig(req), + options = await generateAuthenticationOptions({ + rpID: config.rpID, + allowCredentials: credentials.rows.map((item) => ({ + id: item.credential_id, + transports: item.transports, + })), + userVerification: "required", + }), + challengeToken = await authChallenge(rows[0].id, "passkey-login", { + challenge: options.challenge, + origin: config.origin, + rpID: config.rpID, + }); + res.json({ options, challengeToken }); + } catch (error) { + next(error); + } +}); +app.post("/api/auth/passkey/verify", async (req, res, next) => { + try { + const challenge = await pool.query( + "SELECT * FROM diary_auth_challenges WHERE token_hash=$1 AND type='passkey-login' AND expires_at>NOW()", + [hash(String(req.body.challengeToken || ""))], + ); + if (!challenge.rows[0]) + return res.status(404).json({ error: "Passkey challenge expired" }); + const passkey = await pool.query( + "SELECT * FROM diary_passkeys WHERE credential_id=$1 AND user_id=$2", + [req.body.response?.id, challenge.rows[0].user_id], + ); + if (!passkey.rows[0]) + return res.status(404).json({ error: "Passkey not found" }); + const item = passkey.rows[0], + payload = challenge.rows[0].payload, + verification = await verifyAuthenticationResponse({ + response: req.body.response, + expectedChallenge: payload.challenge, + expectedOrigin: payload.origin, + expectedRPID: payload.rpID, + credential: { + id: item.credential_id, + publicKey: new Uint8Array(item.public_key), + counter: +item.counter, + transports: item.transports, + }, + requireUserVerification: true, + }); + if (!verification.verified) + return res.status(401).json({ error: "Passkey verification failed" }); + await pool.query( + "UPDATE diary_passkeys SET counter=$1,last_used_at=NOW() WHERE id=$2", + [verification.authenticationInfo.newCounter, item.id], + ); + await pool.query("DELETE FROM diary_auth_challenges WHERE token_hash=$1", [ + hash(req.body.challengeToken), + ]); + const userResult = await pool.query( + "SELECT * FROM diary_users WHERE id=$1", + [item.user_id], + ), + user = userResult.rows[0]; + await login(user, req, res, "passkey"); + const { password_hash, totp_secret, recovery_code_hashes, ...safeUser } = + user; + res.json(safeUser); + } catch (error) { + next(error); + } +}); +app.post("/api/security/2fa/setup", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT password_hash,two_factor_enabled FROM diary_users WHERE id=$1", + [req.user.id], + ); + if (!(await verify(String(req.body.password || ""), rows[0].password_hash))) + return res.status(401).json({ error: "Current password is incorrect" }); + if (rows[0].two_factor_enabled) + return res + .status(409) + .json({ error: "Two-factor authentication is already enabled" }); + const secret = base32Encode(crypto.randomBytes(20)), + issuer = encodeURIComponent("Daymark"), + account = encodeURIComponent(req.user.email), + uri = `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`, + challenge = await authChallenge(req.user.id, "setup-2fa", { + secret: encryptSecret(secret), + }); + res.json({ challenge, secret, qr: await QRCode.toDataURL(uri), uri }); + } catch (error) { + next(error); + } +}); +app.post("/api/security/2fa/confirm", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT * FROM diary_auth_challenges WHERE token_hash=$1 AND user_id=$2 AND type='setup-2fa' AND expires_at>NOW()", + [hash(String(req.body.challenge || "")), req.user.id], + ); + if (!rows[0]) + return res.status(404).json({ error: "Setup challenge expired" }); + const encryptedSecret = rows[0].payload.secret, + secret = decryptSecret(encryptedSecret); + if (!verifyTotp(secret, req.body.code)) + return res.status(401).json({ error: "Invalid authenticator code" }); + const recoveryCodes = Array.from( + { length: 10 }, + () => + `${base32Encode(crypto.randomBytes(5)).slice(0, 4)}-${base32Encode(crypto.randomBytes(5)).slice(0, 4)}`, + ); + await pool.query( + "UPDATE diary_users SET totp_secret=$1,two_factor_enabled=true,recovery_code_hashes=$2 WHERE id=$3", + [ + encryptedSecret, + JSON.stringify(recoveryCodes.map((code) => hash(code))), + req.user.id, + ], + ); + await pool.query("DELETE FROM diary_auth_challenges WHERE token_hash=$1", [ + hash(req.body.challenge), + ]); + res.json({ enabled: true, recoveryCodes }); + } catch (error) { + next(error); + } +}); +app.delete("/api/security/2fa", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT password_hash,totp_secret,two_factor_enabled FROM diary_users WHERE id=$1", + [req.user.id], + ); + if (!(await verify(String(req.body.password || ""), rows[0].password_hash))) + return res.status(401).json({ error: "Current password is incorrect" }); + if ( + !rows[0].two_factor_enabled || + !verifyTotp(decryptSecret(rows[0].totp_secret), req.body.code) + ) + return res.status(401).json({ error: "Invalid authenticator code" }); + await pool.query( + "UPDATE diary_users SET totp_secret=NULL,two_factor_enabled=false,recovery_code_hashes='[]' WHERE id=$1", + [req.user.id], + ); + await pool.query("DELETE FROM diary_auth_challenges WHERE user_id=$1", [ + req.user.id, + ]); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.post("/api/auth/verification/resend", auth, async (req, res, next) => { + try { + if (req.user.email_verified_at) return res.status(204).end(); + await sendAccountToken(req, req.user, "verify"); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.post("/api/auth/verify/:token", async (req, res, next) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const { rows } = await client.query( + "SELECT * FROM diary_account_tokens WHERE token_hash=$1 AND type='verify' AND used_at IS NULL AND expires_at>NOW() FOR UPDATE", + [hash(req.params.token)], + ); + if (!rows[0]) { + await client.query("ROLLBACK"); + return res + .status(404) + .json({ error: "Verification link is invalid or expired" }); + } + await client.query( + "UPDATE diary_users SET email_verified_at=NOW() WHERE id=$1", + [rows[0].user_id], + ); + await client.query( + "UPDATE diary_account_tokens SET used_at=NOW() WHERE id=$1", + [rows[0].id], + ); + await client.query("COMMIT"); + res.json({ verified: true }); + } catch (error) { + await client.query("ROLLBACK"); + next(error); + } finally { + client.release(); + } +}); +app.post("/api/auth/password/request", async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT id,email,display_name FROM diary_users WHERE email=lower($1)", + [String(req.body.email || "")], + ); + if (rows[0]) + await sendAccountToken(req, rows[0], "reset").catch(console.error); + res.json({ message: "If that address exists, a reset link has been sent" }); + } catch (error) { + next(error); + } +}); +app.post("/api/auth/password/reset/:token", async (req, res, next) => { + const client = await pool.connect(); + try { + const pass = String(req.body.password || ""); + if (pass.length < 10) + return res + .status(400) + .json({ error: "Password needs at least 10 characters" }); + await client.query("BEGIN"); + const { rows } = await client.query( + "SELECT * FROM diary_account_tokens WHERE token_hash=$1 AND type='reset' AND used_at IS NULL AND expires_at>NOW() FOR UPDATE", + [hash(req.params.token)], + ); + if (!rows[0]) { + await client.query("ROLLBACK"); + return res + .status(404) + .json({ error: "Reset link is invalid or expired" }); + } + await client.query("UPDATE diary_users SET password_hash=$1 WHERE id=$2", [ + await password(pass), + rows[0].user_id, + ]); + await client.query( + "UPDATE diary_account_tokens SET used_at=NOW() WHERE id=$1", + [rows[0].id], + ); + await client.query("DELETE FROM diary_sessions WHERE user_id=$1", [ + rows[0].user_id, + ]); + await client.query("COMMIT"); + res.json({ reset: true }); + } catch (error) { + await client.query("ROLLBACK"); + next(error); + } finally { + client.release(); + } +}); +function admin(req, res, next) { + return req.user.is_admin + ? next() + : res.status(403).json({ error: "Administrator required" }); +} +async function auditAdmin( + req, + action, + targetType = "", + targetId = "", + details = {}, + executor = pool, +) { + await executor.query( + "INSERT INTO diary_admin_audit(admin_id,action,target_type,target_id,details,ip_address) VALUES($1,$2,$3,$4,$5,$6)", + [ + req.user.id, + action, + targetType, + String(targetId || ""), + JSON.stringify(details), + req.ip || "", + ], + ); +} +async function backupInventory() { + await mkdir(backupPath, { recursive: true }); + const files = await readdir(backupPath), + backups = new Map(); + for (const name of files) { + const match = name.match(/^(diary|media)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)\.(dump|tar\.gz)$/); + if (!match) continue; + const [, type, stamp] = match, + information = await stat(path.join(backupPath, name)), + item = backups.get(stamp) || { + stamp, + createdAt: information.mtime.toISOString(), + database: null, + media: null, + }; + item[type === "diary" ? "database" : "media"] = { + name, + bytes: information.size, + }; + if (information.mtime.toISOString() < item.createdAt) + item.createdAt = information.mtime.toISOString(); + backups.set(stamp, item); + } + return [...backups.values()] + .map((item) => ({ + ...item, + complete: !!item.database && !!item.media, + bytes: (item.database?.bytes || 0) + (item.media?.bytes || 0), + })) + .sort((a, b) => b.stamp.localeCompare(a.stamp)); +} +app.get("/api/admin/backups", auth, admin, async (_req, res, next) => { + try { + res.json({ running: backupRunning, backups: await backupInventory() }); + } catch (error) { + next(error); + } +}); +app.post("/api/admin/backups", auth, admin, async (req, res, next) => { + if (backupRunning) + return res.status(409).json({ error: "A backup is already running" }); + backupRunning = true; + const stamp = new Date().toISOString().replace(/:/g, "-").replace(/\.\d{3}Z$/, "Z"), + databaseFile = path.join(backupPath, `diary-${stamp}.dump`), + mediaFile = path.join(backupPath, `media-${stamp}.tar.gz`); + try { + await mkdir(backupPath, { recursive: true }); + const pgEnvironment = { + ...process.env, + PGHOST: process.env.DB_HOST || "database", + PGPORT: process.env.DB_PORT || "5432", + PGDATABASE: process.env.DB_NAME || "diary", + PGUSER: process.env.DB_USER || "diary_user", + PGPASSWORD: process.env.DB_PASSWORD || "", + }; + await runFile("pg_dump", ["--format=custom", "--file", databaseFile], { + env: pgEnvironment, + timeout: 30 * 60 * 1000, + }); + await runFile("tar", ["-czf", mediaFile, "-C", "/minio-data", "."], { + timeout: 30 * 60 * 1000, + }); + await auditAdmin(req, "backup.created", "backup", stamp); + const backups = await backupInventory(); + res.status(201).json(backups.find((item) => item.stamp === stamp)); + } catch (error) { + await Promise.all([ + unlink(databaseFile).catch(() => {}), + unlink(mediaFile).catch(() => {}), + ]); + next(error); + } finally { + backupRunning = false; + } +}); +app.get( + "/api/admin/backups/:stamp/:type", + auth, + admin, + async (req, res, next) => { + try { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/.test(req.params.stamp)) + return res.status(400).json({ error: "Invalid backup timestamp" }); + const fileName = + req.params.type === "database" + ? `diary-${req.params.stamp}.dump` + : req.params.type === "media" + ? `media-${req.params.stamp}.tar.gz` + : null; + if (!fileName) + return res.status(400).json({ error: "Invalid backup type" }); + res.download(path.join(backupPath, fileName), fileName); + } catch (error) { + next(error); + } + }, +); +app.delete("/api/admin/backups/:stamp", auth, admin, async (req, res, next) => { + try { + const { stamp } = req.params; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/.test(stamp)) + return res.status(400).json({ error: "Invalid backup timestamp" }); + let deleted = 0; + for (const name of [`diary-${stamp}.dump`, `media-${stamp}.tar.gz`]) { + try { + await unlink(path.join(backupPath, name)); + deleted += 1; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + if (!deleted) return res.status(404).json({ error: "Backup not found" }); + await auditAdmin(req, "backup.deleted", "backup", stamp, { files: deleted }); + res.json({ deleted: true }); + } catch (error) { + next(error); + } +}); +app.get("/api/admin/moderation", auth, admin, async (_req, res, next) => { + try { + const comments = await pool.query( + `SELECT c.id,c.entry_id,c.parent_id,c.body,c.created_at,c.updated_at,u.id AS user_id,u.display_name,u.username,e.title AS entry_title,j.name AS journal_name FROM diary_comments c JOIN diary_users u ON u.id=c.user_id JOIN diary_entries e ON e.id=c.entry_id JOIN diary_journals j ON j.id=e.journal_id WHERE c.deleted_at IS NULL ORDER BY c.created_at DESC LIMIT 200`, + ), + links = await pool.query( + `SELECT l.id,l.entry_id,l.expires_at,l.revoked_at,l.view_count,l.created_at,l.last_viewed_at,(l.password_hash IS NOT NULL) AS password_protected,e.title AS entry_title,u.display_name,u.username FROM diary_public_links l JOIN diary_entries e ON e.id=l.entry_id LEFT JOIN diary_users u ON u.id=l.created_by ORDER BY l.created_at DESC LIMIT 200`, + ); + res.json({ comments: comments.rows, links: links.rows }); + } catch (error) { + next(error); + } +}); +app.delete( + "/api/admin/moderation/comments/:id", + auth, + admin, + async (req, res, next) => { + try { + const { rows } = await pool.query( + "DELETE FROM diary_comments WHERE id=$1 RETURNING id,entry_id,user_id,left(body,160) AS excerpt", + [+req.params.id], + ); + if (!rows[0]) return res.status(404).json({ error: "Comment not found" }); + await auditAdmin(req, "moderation.comment_deleted", "comment", rows[0].id, { + entryId: rows[0].entry_id, + userId: rows[0].user_id, + excerpt: rows[0].excerpt, + }); + res.json({ deleted: true }); + } catch (error) { + next(error); + } + }, +); +app.delete( + "/api/admin/moderation/public-links/:id", + auth, + admin, + async (req, res, next) => { + try { + const { rows } = await pool.query( + "UPDATE diary_public_links SET revoked_at=COALESCE(revoked_at,NOW()) WHERE id=$1 RETURNING id,entry_id,view_count", + [+req.params.id], + ); + if (!rows[0]) + return res.status(404).json({ error: "Public link not found" }); + await auditAdmin( + req, + "moderation.public_link_revoked", + "public_link", + rows[0].id, + { entryId: rows[0].entry_id, views: rows[0].view_count }, + ); + res.json({ revoked: true }); + } catch (error) { + next(error); + } + }, +); +app.post("/api/admin/reports/:format", auth, admin, async (req, res, next) => { + try { + const format = req.params.format, + days = Math.min(365, Math.max(7, Math.round(Number(req.query.days) || 30))); + if (!['csv', 'pdf'].includes(format)) + return res.status(400).json({ error: "Report format must be CSV or PDF" }); + const totals = await pool.query( + `SELECT (SELECT count(*) FROM diary_users)::int AS users,(SELECT count(*) FROM diary_users WHERE disabled_at IS NULL)::int AS active_users,(SELECT count(*) FROM diary_entries WHERE deleted_at IS NULL)::int AS entries,(SELECT count(*) FROM diary_journals)::int AS journals,(SELECT count(*) FROM diary_comments)::int AS comments,(SELECT count(*) FROM diary_public_links WHERE revoked_at IS NULL AND expires_at>NOW())::int AS active_public_links,(SELECT count(*) FROM diary_login_history WHERE NOT success AND created_at>NOW()-INTERVAL '24 hours')::int AS failed_logins_24h`, + ), + daily = await pool.query( + `WITH days AS (SELECT generate_series(CURRENT_DATE-($1::int-1),CURRENT_DATE,INTERVAL '1 day')::date AS day) SELECT day::text AS date,(SELECT count(*) FROM diary_entries WHERE created_at>=day AND created_at=day AND created_at=day AND created_at=day AND created_at=day AND created_at>'size' ~ '^[0-9]+$' THEN (item->>'size')::bigint ELSE 0 END),0)::bigint FROM diary_entries se CROSS JOIN LATERAL jsonb_array_elements(COALESCE(se.media,'[]'::jsonb)) item WHERE se.user_id=u.id AND se.deleted_at IS NULL) AS storage_used FROM diary_users u LEFT JOIN diary_entries e ON e.user_id=u.id GROUP BY u.id ORDER BY entries DESC,u.display_name`, + [storageQuota], + ), + visibility = await pool.query( + `SELECT visibility,count(*)::int AS count FROM diary_entries WHERE deleted_at IS NULL GROUP BY visibility ORDER BY count DESC`, + ), + audit = await pool.query( + `SELECT a.action,a.target_type,a.target_id,a.created_at,u.display_name,u.username FROM diary_admin_audit a LEFT JOIN diary_users u ON u.id=a.admin_id ORDER BY a.created_at DESC LIMIT 50`, + ), + generatedAt = new Date(), + fileDate = generatedAt.toISOString().slice(0, 10), + byteLabel = (value) => { + const bytes = +value || 0; + if (!bytes) return "0 B"; + const unit = Math.min(4, Math.floor(Math.log(bytes) / Math.log(1024))); + return `${(bytes / 1024 ** unit).toFixed(unit ? 1 : 0)} ${["B", "KB", "MB", "GB", "TB"][unit]}`; + }; + await auditAdmin(req, "analytics.report_generated", "report", format, { + days, + }); + if (format === "csv") { + const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`, + rows = [["section", "date", "metric", "value", "details"]]; + for (const [metric, value] of Object.entries(totals.rows[0])) + rows.push(["summary", generatedAt.toISOString(), metric, value, ""]); + for (const item of daily.rows) + for (const metric of ["entries", "comments", "users", "successful_logins", "failed_logins"]) + rows.push(["daily", item.date, metric, item[metric], `${days}-day range`]); + for (const item of visibility.rows) + rows.push(["visibility", generatedAt.toISOString(), item.visibility, item.count, "entries"]); + for (const item of users.rows) + rows.push(["user", generatedAt.toISOString(), item.username, item.entries, `${item.display_name}; ${item.email}; storage ${byteLabel(item.storage_used)} / ${byteLabel(item.storage_quota)}; admin ${item.is_admin}; disabled ${item.disabled}`]); + for (const item of audit.rows) + rows.push(["audit", item.created_at, item.action, item.target_id, `${item.display_name || "deleted administrator"}; ${item.target_type}`]); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename=diary-admin-report-${fileDate}.csv`); + return res.send(`\uFEFF${rows.map((row) => row.map(escape).join(",")).join("\n")}`); + } + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename=diary-admin-report-${fileDate}.pdf`); + const doc = new PDFDocument({ margin: 48, size: "A4", bufferPages: true }); + doc.pipe(res); + const heading = (title) => { + if (doc.y > 700) doc.addPage(); + doc.moveDown(0.7).fontSize(13).fillColor("#1f3a5f").text(title).moveDown(0.35); + }, + line = (label, value, detail = "") => { + if (doc.y > 750) doc.addPage(); + doc.fontSize(8).fillColor("#111827").text(`${label}: ${value}`, { continued: !!detail }); + if (detail) doc.fillColor("#6b7280").text(` ${detail}`); + }; + doc.fontSize(22).fillColor("#111827").text("Daymark", { align: "left" }); + doc.fontSize(11).fillColor("#4b5563").text("Administrator analytics report"); + doc.fontSize(8).text(`Generated ${generatedAt.toLocaleString("en-GB")} · Reporting period ${days} days`); + heading("Site summary"); + for (const [metric, value] of Object.entries(totals.rows[0])) + line(metric.replaceAll("_", " ").toUpperCase(), value); + heading("Daily activity"); + for (const item of daily.rows) + line(item.date, `${item.entries} entries · ${item.comments} comments · ${item.users} users`, `${item.successful_logins} successful / ${item.failed_logins} failed logins`); + heading("Entry visibility"); + for (const item of visibility.rows) line(item.visibility.toUpperCase(), item.count); + heading("Users and storage"); + for (const item of users.rows) + line(`${item.display_name} (@${item.username})`, `${item.entries} entries`, `${byteLabel(item.storage_used)} / ${byteLabel(item.storage_quota)} · ${item.is_admin ? "admin" : "user"}${item.disabled ? " · disabled" : ""}`); + heading("Recent administrator activity"); + for (const item of audit.rows) + line(new Date(item.created_at).toLocaleString("en-GB"), item.action, `${item.display_name || "deleted administrator"} · ${item.target_type}${item.target_id ? ` #${item.target_id}` : ""}`); + doc.end(); + } catch (error) { + next(error); + } +}); +app.get("/api/site-settings", async (_req, res, next) => { + try { + const status = await maintenanceStatus(), + { rows } = await pool.query( + "SELECT site_name,site_tagline,site_notice,footer_text FROM diary_site_settings WHERE id=true", + ); + res.json({ ...rows[0], ...status }); + } catch (error) { + next(error); + } +}); +app.get("/api/authors/:username", async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT id,username,display_name,bio,preferences,created_at FROM diary_users WHERE username=lower($1) AND disabled_at IS NULL", + [req.params.username], + ); + const author = rows[0], preferences = { ...defaultUserPreferences, ...(author?.preferences || {}) }; + if (!author || preferences.profileVisibility !== "public") + return res.status(404).json({ error: "Public profile not found" }); + const entries = await pool.query( + "SELECT e.*,u.display_name FROM diary_entries e JOIN diary_users u ON u.id=e.user_id WHERE e.user_id=$1 AND e.visibility='public' AND e.deleted_at IS NULL AND NOT e.is_encrypted ORDER BY e.entry_date DESC,e.created_at DESC LIMIT 100", + [author.id], + ); + res.json({ + profile: { username: author.username, displayName: author.display_name, bio: author.bio, joinedAt: author.created_at, avatarUrl: preferences.avatarUrl?.startsWith("/api/media/") ? `/api/authors/${author.username}/avatar` : preferences.avatarUrl, pronouns: preferences.pronouns, personalWebsite: preferences.personalWebsite }, + entries: entries.rows.map((item) => ({ ...entry(item), media: [] })), + }); + } catch (error) { + next(error); + } +}); +app.get("/api/authors/:username/avatar", async (req, res, next) => { + try { + const { rows } = await pool.query("SELECT id,preferences FROM diary_users WHERE username=lower($1) AND disabled_at IS NULL", [req.params.username]), + user = rows[0], preferences = { ...defaultUserPreferences, ...(user?.preferences || {}) }; + if (!user || preferences.profileVisibility !== "public" || !preferences.avatarUrl?.startsWith("/api/media/")) return res.status(404).end(); + const key = decodeURIComponent(preferences.avatarUrl.slice("/api/media/".length)); + if (!key.startsWith(`${user.id}/`)) return res.status(403).end(); + const stat = await mediaStore.statObject(mediaBucket, key); + res.setHeader("Content-Type", stat.metaData["content-type"] || "image/jpeg"); + res.setHeader("Cache-Control", "public, max-age=3600"); + (await mediaStore.getObject(mediaBucket, key)).pipe(res); + } catch (error) { + if (error.code === "NoSuchKey" || error.code === "NotFound") return res.status(404).end(); + next(error); + } +}); +app.get("/api/admin", auth, admin, async (_req, res, next) => { + try { + const users = await pool.query( + `SELECT u.id,u.username,u.email,u.display_name,u.bio,u.is_admin,u.disabled_at,u.email_verified_at,u.created_at,COALESCE(u.storage_quota,$1)::bigint AS storage_quota,(SELECT COALESCE(sum(CASE WHEN item->>'size' ~ '^[0-9]+$' THEN (item->>'size')::bigint ELSE 0 END),0)::bigint FROM diary_entries se CROSS JOIN LATERAL jsonb_array_elements(COALESCE(se.media,'[]'::jsonb)) item WHERE se.user_id=u.id AND se.deleted_at IS NULL) AS storage_used,count(DISTINCT e.id) FILTER (WHERE e.deleted_at IS NULL) AS entry_count,max(h.created_at) FILTER (WHERE h.success) AS last_login FROM diary_users u LEFT JOIN diary_entries e ON e.user_id=u.id LEFT JOIN diary_login_history h ON h.user_id=u.id GROUP BY u.id ORDER BY u.created_at`, + [storageQuota], + ), + settings = await pool.query( + "SELECT registration_mode,site_name,site_tagline,site_notice,footer_text,alert_memory_percent,alert_db_connections_percent,alert_failed_logins,alert_backup_hours,alert_storage_percent,maintenance_enabled,maintenance_starts_at,maintenance_ends_at,maintenance_message FROM diary_site_settings WHERE id=true", + ), + totals = await pool.query( + "SELECT (SELECT count(*) FROM diary_users)::int AS users,(SELECT count(*) FROM diary_users WHERE disabled_at IS NULL)::int AS active_users,(SELECT count(*) FROM diary_entries WHERE deleted_at IS NULL)::int AS entries,(SELECT count(*) FROM diary_entries WHERE deleted_at IS NOT NULL)::int AS trashed_entries,(SELECT count(*) FROM diary_journals)::int AS journals,(SELECT count(*) FROM diary_comments)::int AS comments,(SELECT count(*) FROM diary_sessions WHERE expires_at>NOW())::int AS active_sessions,(SELECT count(*) FROM diary_login_history WHERE NOT success AND created_at>NOW()-INTERVAL '24 hours')::int AS failed_logins_24h", + ), + invitations = await pool.query( + "SELECT i.id,i.max_uses,i.use_count,i.expires_at,i.created_at,u.display_name AS created_by_name FROM diary_registration_invites i LEFT JOIN diary_users u ON u.id=i.created_by ORDER BY i.created_at DESC LIMIT 100", + ), + loginActivity = await pool.query( + "SELECT h.id,h.identifier,h.success,h.method,h.ip_address,h.created_at,u.display_name,u.username FROM diary_login_history h LEFT JOIN diary_users u ON u.id=h.user_id ORDER BY h.created_at DESC LIMIT 50", + ), + media = await pool.query( + `SELECT count(*)::int AS files,COALESCE(sum(CASE WHEN item->>'size' ~ '^[0-9]+$' THEN (item->>'size')::bigint ELSE 0 END),0)::bigint AS bytes FROM diary_entries e CROSS JOIN LATERAL jsonb_array_elements(COALESCE(e.media,'[]'::jsonb)) item WHERE e.deleted_at IS NULL`, + ), + system = await pool.query( + "SELECT current_database() AS database,current_setting('server_version') AS database_version,NOW() AS server_time,pg_database_size(current_database())::bigint AS database_bytes,(SELECT count(*)::int FROM pg_stat_activity WHERE datname=current_database()) AS connections,current_setting('max_connections')::int AS max_connections", + ), + tableSizes = await pool.query( + `SELECT relname AS name,pg_total_relation_size(relid)::bigint AS bytes,n_live_tup::bigint AS rows FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 12`, + ), + dailyActivity = await pool.query( + `WITH days AS (SELECT generate_series(CURRENT_DATE-29,CURRENT_DATE,INTERVAL '1 day')::date AS day) SELECT day::text AS date,(SELECT count(*) FROM diary_entries WHERE created_at>=day AND created_at=day AND created_at=day AND created_at=day AND created_at=day AND created_at []), + latestCompleteBackup = backupItems.find((item) => item.complete), + backupAgeHours = latestCompleteBackup + ? (Date.now() - new Date(latestCompleteBackup.createdAt).getTime()) / 3600000 + : Infinity, + alerts = []; + if (hostMemoryPercent >= alertSettings.alert_memory_percent) + alerts.push({ + id: "memory", + severity: "warning", + title: "High host memory usage", + detail: `${hostMemoryPercent.toFixed(1)}% used; threshold ${alertSettings.alert_memory_percent}%`, + }); + if (connectionPercent >= alertSettings.alert_db_connections_percent) + alerts.push({ + id: "database-connections", + severity: "warning", + title: "High database connection usage", + detail: `${connectionPercent.toFixed(1)}% used; threshold ${alertSettings.alert_db_connections_percent}%`, + }); + if (+totals.rows[0].failed_logins_24h >= alertSettings.alert_failed_logins) + alerts.push({ + id: "failed-logins", + severity: "critical", + title: "Elevated failed sign-ins", + detail: `${totals.rows[0].failed_logins_24h} failures in 24 hours; threshold ${alertSettings.alert_failed_logins}`, + }); + if (backupAgeHours >= alertSettings.alert_backup_hours) + alerts.push({ + id: "backup-age", + severity: "critical", + title: latestCompleteBackup ? "Backup is overdue" : "No complete backup found", + detail: latestCompleteBackup + ? `${backupAgeHours.toFixed(1)} hours since the latest complete backup` + : "Create a complete database and media backup", + }); + for (const item of users.rows) { + const percent = (+item.storage_used / Math.max(1, +item.storage_quota || storageQuota)) * 100; + if (percent >= alertSettings.alert_storage_percent) + alerts.push({ + id: `storage-${item.id}`, + severity: percent >= 100 ? "critical" : "warning", + title: `${item.display_name} storage ${percent >= 100 ? "exceeded" : "nearly full"}`, + detail: `${percent.toFixed(1)}% of the account quota is used`, + userId: +item.id, + }); + } + for (const item of backupItems.filter((backup) => !backup.complete).slice(0, 5)) + alerts.push({ + id: `backup-incomplete-${item.stamp}`, + severity: "warning", + title: "Incomplete backup pair", + detail: `${item.stamp} is missing ${item.database ? "media" : "database"}`, + }); + res.json({ + settings: settings.rows[0], + totals: totals.rows[0], + users: users.rows.map((user) => ({ + ...user, + id: +user.id, + entry_count: +user.entry_count, + storage_quota: +user.storage_quota, + storage_used: +user.storage_used, + })), + invitations: invitations.rows, + loginActivity: loginActivity.rows, + media: { files: +media.rows[0].files, bytes: +media.rows[0].bytes }, + analytics: { + daily: dailyActivity.rows, + visibility: visibility.rows, + contributors: contributors.rows, + }, + adminAudit: adminAudit.rows, + alerts, + system: { + ...system.rows[0], + database_bytes: +system.rows[0].database_bytes, + tables: tableSizes.rows.map((table) => ({ + ...table, + bytes: +table.bytes, + rows: +table.rows, + })), + host: { + hostname: os.hostname(), + platform: `${os.platform()} ${os.release()} (${os.arch()})`, + cpu_model: os.cpus()[0]?.model || "Unknown", + cpu_cores: os.cpus().length, + load: os.loadavg(), + total_memory: os.totalmem(), + free_memory: os.freemem(), + system_uptime: os.uptime(), + process_uptime: process.uptime(), + process_memory: process.memoryUsage().rss, + node_version: process.version, + }, + }, + }); + } catch (error) { + next(error); + } +}); +app.put("/api/admin/settings", auth, admin, async (req, res, next) => { + try { + const mode = ["open", "invite", "closed"].includes( + req.body.registrationMode, + ) + ? req.body.registrationMode + : null; + if (!mode) + return res.status(400).json({ error: "Invalid registration mode" }); + await pool.query( + "UPDATE diary_site_settings SET registration_mode=$1,updated_at=NOW() WHERE id=true", + [mode], + ); + await auditAdmin(req, "registration.mode_changed", "site", "settings", { + mode, + }); + res.json({ registrationMode: mode }); + } catch (error) { + next(error); + } +}); +app.put("/api/admin/site-settings", auth, admin, async (req, res, next) => { + try { + const siteName = String(req.body.siteName || "").trim().slice(0, 80), + siteTagline = String(req.body.siteTagline || "").trim().slice(0, 240), + siteNotice = String(req.body.siteNotice || "").trim().slice(0, 160), + footerText = String(req.body.footerText || "").trim().slice(0, 160); + if (!siteName || !siteTagline) + return res + .status(400) + .json({ error: "Site name and description are required" }); + const { rows } = await pool.query( + `UPDATE diary_site_settings SET site_name=$1,site_tagline=$2,site_notice=$3,footer_text=$4,updated_at=NOW() WHERE id=true RETURNING site_name,site_tagline,site_notice,footer_text`, + [siteName, siteTagline, siteNotice, footerText], + ); + await auditAdmin(req, "website.settings_updated", "site", "settings", { + siteName, + }); + res.json(rows[0]); + } catch (error) { + next(error); + } +}); +app.put("/api/admin/alert-settings", auth, admin, async (req, res, next) => { + try { + const values = { + memory: Math.round(Number(req.body.memory)), + connections: Math.round(Number(req.body.connections)), + failedLogins: Math.round(Number(req.body.failedLogins)), + backupHours: Math.round(Number(req.body.backupHours)), + storage: Math.round(Number(req.body.storage)), + }; + if ( + !Number.isFinite(values.memory) || + values.memory < 10 || + values.memory > 100 || + !Number.isFinite(values.connections) || + values.connections < 10 || + values.connections > 100 || + !Number.isFinite(values.failedLogins) || + values.failedLogins < 1 || + values.failedLogins > 10000 || + !Number.isFinite(values.backupHours) || + values.backupHours < 1 || + values.backupHours > 720 || + !Number.isFinite(values.storage) || + values.storage < 10 || + values.storage > 100 + ) + return res.status(400).json({ error: "Invalid alert thresholds" }); + await pool.query( + `UPDATE diary_site_settings SET alert_memory_percent=$1,alert_db_connections_percent=$2,alert_failed_logins=$3,alert_backup_hours=$4,alert_storage_percent=$5,updated_at=NOW() WHERE id=true`, + [ + values.memory, + values.connections, + values.failedLogins, + values.backupHours, + values.storage, + ], + ); + await auditAdmin(req, "alerts.thresholds_updated", "site", "settings", values); + res.json(values); + } catch (error) { + next(error); + } +}); +app.put("/api/admin/maintenance", auth, admin, async (req, res, next) => { + try { + const enabled = !!req.body.enabled, + startsAt = req.body.startsAt ? new Date(req.body.startsAt) : null, + endsAt = req.body.endsAt ? new Date(req.body.endsAt) : null, + message = String(req.body.message || "").trim().slice(0, 500); + if ( + (startsAt && Number.isNaN(startsAt.getTime())) || + (endsAt && Number.isNaN(endsAt.getTime())) || + (startsAt && endsAt && endsAt <= startsAt) || + !message + ) + return res.status(400).json({ + error: "Use a valid maintenance schedule and message", + }); + const { rows } = await pool.query( + `UPDATE diary_site_settings SET maintenance_enabled=$1,maintenance_starts_at=$2,maintenance_ends_at=$3,maintenance_message=$4,updated_at=NOW() WHERE id=true RETURNING maintenance_enabled,maintenance_starts_at,maintenance_ends_at,maintenance_message`, + [enabled, startsAt, endsAt, message], + ); + maintenanceCache = { expires: 0, value: null }; + await auditAdmin(req, "maintenance.schedule_updated", "site", "maintenance", { + enabled, + startsAt, + endsAt, + }); + res.json(rows[0]); + } catch (error) { + next(error); + } +}); +app.post("/api/admin/users", auth, admin, async (req, res, next) => { + const client = await pool.connect(); + try { + const username = String(req.body.username || "").trim(), + email = String(req.body.email || "").trim(), + displayName = String(req.body.displayName || "").trim(), + pass = String(req.body.password || ""), + quotaGb = Number(req.body.storageQuotaGb || 0), + quotaBytes = quotaGb > 0 ? Math.round(quotaGb * 1024 ** 3) : null; + if ( + !/^[a-z0-9_-]{3,30}$/i.test(username) || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || + pass.length < 10 || + (req.body.storageQuotaGb && + (!Number.isFinite(quotaGb) || quotaGb < 0.1 || quotaGb > 1024)) + ) + return res.status(400).json({ + error: + "Use a valid username, email, and password of at least 10 characters", + }); + await client.query("BEGIN"); + const { rows } = await client.query( + `INSERT INTO diary_users(username,email,password_hash,display_name,bio,is_admin,email_verified_at,disabled_at,storage_quota) VALUES(lower($1),lower($2),$3,$4,$5,$6,CASE WHEN $7 THEN NOW() ELSE NULL END,CASE WHEN $8 THEN NOW() ELSE NULL END,$9) RETURNING id,username,email,display_name`, + [ + username, + email, + await password(pass), + displayName || username, + String(req.body.bio || "").slice(0, 2000), + !!req.body.isAdmin, + !!req.body.verified, + !!req.body.disabled, + quotaBytes, + ], + ); + await client.query( + "INSERT INTO diary_journals(owner_id,name,is_default) VALUES($1,'Field Notes',true)", + [rows[0].id], + ); + await auditAdmin( + req, + "user.created", + "user", + rows[0].id, + { username: rows[0].username, email: rows[0].email }, + client, + ); + await client.query("COMMIT"); + res.status(201).json(rows[0]); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + if (error.code === "23505") + return res.status(409).json({ error: "Username or email already exists" }); + next(error); + } finally { + client.release(); + } +}); +app.put("/api/admin/users/:id/details", auth, admin, async (req, res, next) => { + const client = await pool.connect(); + try { + const targetId = +req.params.id, + username = String(req.body.username || "").trim(), + email = String(req.body.email || "").trim(), + displayName = String(req.body.displayName || "").trim(), + pass = String(req.body.password || ""), + quotaGb = Number(req.body.storageQuotaGb || 0), + quotaBytes = quotaGb > 0 ? Math.round(quotaGb * 1024 ** 3) : storageQuota; + if ( + !/^[a-z0-9_-]{3,30}$/i.test(username) || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || + !displayName || + (pass && pass.length < 10) || + !Number.isFinite(quotaGb) || + quotaGb < 0.1 || + quotaGb > 1024 + ) + return res.status(400).json({ error: "Invalid account details" }); + if ( + targetId === +req.user.id && + (req.body.disabled || req.body.isAdmin === false) + ) + return res.status(400).json({ + error: "You cannot disable or demote your own active account", + }); + await client.query("BEGIN"); + const current = await client.query( + "SELECT is_admin FROM diary_users WHERE id=$1 FOR UPDATE", + [targetId], + ); + if (!current.rowCount) { + await client.query("ROLLBACK"); + return res.status(404).json({ error: "User not found" }); + } + if (current.rows[0].is_admin && req.body.isAdmin === false) { + const admins = await client.query( + "SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL", + ); + if (admins.rows[0].count <= 1) { + await client.query("ROLLBACK"); + return res.status(409).json({ + error: "At least one active administrator is required", + }); + } + } + await client.query( + `UPDATE diary_users SET username=lower($1),email=lower($2),display_name=$3,bio=$4,is_admin=$5,email_verified_at=CASE WHEN $6 THEN COALESCE(email_verified_at,NOW()) ELSE NULL END,disabled_at=CASE WHEN $7 THEN COALESCE(disabled_at,NOW()) ELSE NULL END,password_hash=CASE WHEN $8='' THEN password_hash ELSE $9 END,storage_quota=$10 WHERE id=$11`, + [ + username, + email, + displayName, + String(req.body.bio || "").slice(0, 2000), + !!req.body.isAdmin, + !!req.body.verified, + !!req.body.disabled, + pass, + pass ? await password(pass) : "", + quotaBytes, + targetId, + ], + ); + if (req.body.disabled || pass) + await client.query("DELETE FROM diary_sessions WHERE user_id=$1", [ + targetId, + ]); + await auditAdmin( + req, + "user.details_updated", + "user", + targetId, + { + username, + email, + isAdmin: !!req.body.isAdmin, + verified: !!req.body.verified, + disabled: !!req.body.disabled, + passwordReset: !!pass, + storageQuota: quotaBytes, + }, + client, + ); + await client.query("COMMIT"); + res.json({ updated: true }); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + if (error.code === "23505") + return res.status(409).json({ error: "Username or email already exists" }); + next(error); + } finally { + client.release(); + } +}); +app.delete("/api/admin/users/:id", auth, admin, async (req, res, next) => { + const client = await pool.connect(); + try { + const targetId = +req.params.id; + if (targetId === +req.user.id) + return res.status(400).json({ error: "You cannot delete yourself" }); + await client.query("BEGIN"); + const current = await client.query( + "SELECT is_admin FROM diary_users WHERE id=$1 FOR UPDATE", + [targetId], + ); + if (!current.rowCount) { + await client.query("ROLLBACK"); + return res.status(404).json({ error: "User not found" }); + } + if (current.rows[0].is_admin) { + const admins = await client.query( + "SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL", + ); + if (admins.rows[0].count <= 1) { + await client.query("ROLLBACK"); + return res.status(409).json({ error: "The last administrator cannot be deleted" }); + } + } + await client.query( + "DELETE FROM diary_entries WHERE user_id=$1 OR journal_id IN (SELECT id FROM diary_journals WHERE owner_id=$1)", + [targetId], + ); + await auditAdmin(req, "user.deleted", "user", targetId, {}, client); + await client.query("DELETE FROM diary_users WHERE id=$1", [targetId]); + await client.query("COMMIT"); + const keys = []; + for await (const object of mediaStore.listObjectsV2( + mediaBucket, + `${targetId}/`, + true, + )) + keys.push(object.name); + if (keys.length) await mediaStore.removeObjects(mediaBucket, keys); + res.json({ deleted: true }); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + next(error); + } finally { + client.release(); + } +}); +app.post( + "/api/admin/registration-invites", + auth, + admin, + async (req, res, next) => { + try { + const code = base32Encode(crypto.randomBytes(10)), + days = Math.min(90, Math.max(1, +(req.body.days || 7))), + uses = Math.min(100, Math.max(1, +(req.body.maxUses || 1))), + { rows } = await pool.query( + "INSERT INTO diary_registration_invites(code_hash,created_by,max_uses,expires_at) VALUES($1,$2,$3,NOW()+($4||' days')::interval) RETURNING id,max_uses,expires_at", + [hash(code), req.user.id, uses, days], + ); + await auditAdmin( + req, + "registration.code_created", + "registration_invite", + rows[0].id, + { maxUses: uses, days }, + ); + res.status(201).json({ ...rows[0], code }); + } catch (error) { + next(error); + } + }, +); +app.delete( + "/api/admin/registration-invites/:id", + auth, + admin, + async (req, res, next) => { + try { + const { rowCount } = await pool.query( + "DELETE FROM diary_registration_invites WHERE id=$1", + [+req.params.id], + ); + if (!rowCount) + return res.status(404).json({ error: "Registration code not found" }); + await auditAdmin( + req, + "registration.code_revoked", + "registration_invite", + req.params.id, + ); + res.json({ deleted: true }); + } catch (error) { + next(error); + } + }, +); +app.delete( + "/api/admin/users/:id/sessions", + auth, + admin, + async (req, res, next) => { + try { + const targetId = +req.params.id; + if (!targetId) + return res.status(400).json({ error: "Invalid user" }); + const { rowCount } = await pool.query( + "DELETE FROM diary_sessions WHERE user_id=$1", + [targetId], + ); + await auditAdmin(req, "user.sessions_revoked", "user", targetId, { + sessions: rowCount, + }); + res.json({ revoked: rowCount }); + } catch (error) { + next(error); + } + }, +); +app.put("/api/admin/users/:id", auth, admin, async (req, res, next) => { + const client = await pool.connect(); + try { + const targetId = +req.params.id; + if ( + targetId === +req.user.id && + (req.body.disabled || req.body.isAdmin === false) + ) + return res.status(400).json({ + error: "You cannot disable or demote your own active account", + }); + await client.query("BEGIN"); + const current = await client.query( + "SELECT * FROM diary_users WHERE id=$1 FOR UPDATE", + [targetId], + ); + if (!current.rows[0]) { + await client.query("ROLLBACK"); + return res.status(404).json({ error: "User not found" }); + } + if (current.rows[0].is_admin && req.body.isAdmin === false) { + const admins = await client.query( + "SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL", + ); + if (admins.rows[0].count <= 1) { + await client.query("ROLLBACK"); + return res + .status(409) + .json({ error: "At least one active administrator is required" }); + } + } + const { rows } = await client.query( + "UPDATE diary_users SET is_admin=COALESCE($1,is_admin),disabled_at=CASE WHEN $2::boolean THEN COALESCE(disabled_at,NOW()) ELSE NULL END WHERE id=$3 RETURNING id,is_admin,disabled_at", + [ + typeof req.body.isAdmin === "boolean" ? req.body.isAdmin : null, + !!req.body.disabled, + targetId, + ], + ); + if (req.body.disabled) + await client.query("DELETE FROM diary_sessions WHERE user_id=$1", [ + targetId, + ]); + await auditAdmin( + req, + "user.access_updated", + "user", + targetId, + { + isAdmin: + typeof req.body.isAdmin === "boolean" ? req.body.isAdmin : undefined, + disabled: !!req.body.disabled, + }, + client, + ); + await client.query("COMMIT"); + res.json(rows[0]); + } catch (error) { + await client.query("ROLLBACK"); + next(error); + } finally { + client.release(); + } +}); +app.get("/api/journals", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT j.*,CASE WHEN j.owner_id=$1 THEN 'owner' ELSE m.role END AS role,count(e.id) FILTER (WHERE e.deleted_at IS NULL) AS entry_count FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 LEFT JOIN diary_entries e ON e.journal_id=j.id WHERE j.owner_id=$1 OR m.user_id=$1 GROUP BY j.id,m.role ORDER BY (j.owner_id=$1) DESC,j.is_default DESC,j.sort_order,j.id`, + [req.user.id], + ); + res.json(rows.map(journal)); + } catch (error) { + next(error); + } +}); +app.post("/api/journals", auth, async (req, res, next) => { + try { + const name = String(req.body.name || "") + .trim() + .slice(0, 80), + color = /^#[0-9a-f]{6}$/i.test(req.body.color) + ? req.body.color + : "#7aa2f7"; + if (!name) return res.status(400).json({ error: "Journal name required" }); + const { rows } = await pool.query( + `INSERT INTO diary_journals(owner_id,name,description,color,visibility,sort_order) VALUES($1,$2,$3,$4,$5,COALESCE((SELECT max(sort_order)+1 FROM diary_journals WHERE owner_id=$1),0)) RETURNING *`, + [ + req.user.id, + name, + String(req.body.description || "").slice(0, 500), + color, + req.body.visibility === "public" ? "public" : "private", + ], + ); + res.status(201).json(journal(rows[0])); + } catch (error) { + if (error.code === "23505") + return res + .status(409) + .json({ error: "A journal with that name already exists" }); + next(error); + } +}); +app.put("/api/journals/:id", auth, async (req, res, next) => { + const client = await pool.connect(); + try { + const name = String(req.body.name || "") + .trim() + .slice(0, 80), + color = /^#[0-9a-f]{6}$/i.test(req.body.color) + ? req.body.color + : "#7aa2f7"; + if (!name) return res.status(400).json({ error: "Journal name required" }); + await client.query("BEGIN"); + if (req.body.isDefault) + await client.query( + "UPDATE diary_journals SET is_default=false WHERE owner_id=$1", + [req.user.id], + ); + const { rows } = await client.query( + `UPDATE diary_journals SET name=$1,description=$2,color=$3,visibility=$4,sort_order=$5,is_default=CASE WHEN $6 THEN true ELSE is_default END,updated_at=NOW() WHERE id=$7 AND owner_id=$8 RETURNING *`, + [ + name, + String(req.body.description || "").slice(0, 500), + color, + req.body.visibility === "public" ? "public" : "private", + Number.isInteger(req.body.sortOrder) ? req.body.sortOrder : 0, + !!req.body.isDefault, + req.params.id, + req.user.id, + ], + ); + if (!rows[0]) { + await client.query("ROLLBACK"); + return res.status(404).json({ error: "Journal not found" }); + } + await client.query("COMMIT"); + res.json(journal(rows[0])); + } catch (error) { + await client.query("ROLLBACK"); + if (error.code === "23505") + return res + .status(409) + .json({ error: "A journal with that name already exists" }); + next(error); + } finally { + client.release(); + } +}); +app.delete("/api/journals/:id", auth, async (req, res, next) => { + try { + const result = await pool.query( + `DELETE FROM diary_journals j WHERE j.id=$1 AND j.owner_id=$2 AND NOT j.is_default AND NOT EXISTS(SELECT 1 FROM diary_entries e WHERE e.journal_id=j.id)`, + [req.params.id, req.user.id], + ); + if (!result.rowCount) + return res + .status(409) + .json({ error: "Only empty, non-default journals can be deleted" }); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.get("/api/journals/:id/members", auth, async (req, res, next) => { + try { + const owned = await pool.query( + "SELECT 1 FROM diary_journals WHERE id=$1 AND owner_id=$2", + [req.params.id, req.user.id], + ); + if (!owned.rowCount) + return res.status(403).json({ error: "Journal owner required" }); + const members = await pool.query( + `SELECT u.id,u.username,u.email,u.display_name,m.role,m.created_at FROM diary_journal_members m JOIN diary_users u ON u.id=m.user_id WHERE m.journal_id=$1 ORDER BY m.created_at`, + [req.params.id], + ); + const invitations = await pool.query( + `SELECT id,email,role,expires_at,accepted_at,created_at FROM diary_journal_invitations WHERE journal_id=$1 AND accepted_at IS NULL AND expires_at>NOW() ORDER BY created_at DESC`, + [req.params.id], + ); + res.json({ members: members.rows, invitations: invitations.rows }); + } catch (error) { + next(error); + } +}); +app.post("/api/journals/:id/invitations", auth, async (req, res, next) => { + try { + const roles = ["editor", "contributor", "viewer"], + role = roles.includes(req.body.role) ? req.body.role : "viewer", + email = String(req.body.email || "") + .trim() + .toLowerCase() + .slice(0, 254), + days = Math.min(30, Math.max(1, +(req.body.days || 7))), + token = crypto.randomBytes(32).toString("base64url"); + const { rows } = await pool.query( + `INSERT INTO diary_journal_invitations(journal_id,invited_by,email,role,token_hash,expires_at) SELECT id,$2,$3,$4,$5,NOW()+($6||' days')::interval FROM diary_journals WHERE id=$1 AND owner_id=$2 RETURNING id,email,role,expires_at`, + [req.params.id, req.user.id, email, role, hash(token), days], + ); + if (!rows[0]) + return res.status(403).json({ error: "Journal owner required" }); + res.status(201).json({ ...rows[0], token, acceptUrl: `/invite/${token}` }); + } catch (error) { + next(error); + } +}); +app.post("/api/invitations/:token/accept", auth, async (req, res, next) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const { rows } = await client.query( + `SELECT i.*,j.owner_id,j.name AS journal_name FROM diary_journal_invitations i JOIN diary_journals j ON j.id=i.journal_id WHERE i.token_hash=$1 AND i.accepted_at IS NULL AND i.expires_at>NOW() FOR UPDATE`, + [hash(req.params.token)], + ); + const invitation = rows[0]; + if (!invitation) { + await client.query("ROLLBACK"); + return res + .status(404) + .json({ error: "Invitation is invalid or expired" }); + } + if (invitation.email && invitation.email !== req.user.email.toLowerCase()) { + await client.query("ROLLBACK"); + return res + .status(403) + .json({ error: "Invitation belongs to another email address" }); + } + if (+invitation.owner_id === +req.user.id) { + await client.query("ROLLBACK"); + return res.status(400).json({ error: "You already own this journal" }); + } + await client.query( + `INSERT INTO diary_journal_members(journal_id,user_id,role) VALUES($1,$2,$3) ON CONFLICT(journal_id,user_id) DO UPDATE SET role=EXCLUDED.role`, + [invitation.journal_id, req.user.id, invitation.role], + ); + await client.query( + "UPDATE diary_journal_invitations SET accepted_at=NOW() WHERE id=$1", + [invitation.id], + ); + await client.query("COMMIT"); + res.json({ + journalId: +invitation.journal_id, + journalName: invitation.journal_name, + role: invitation.role, + }); + } catch (error) { + await client.query("ROLLBACK"); + next(error); + } finally { + client.release(); + } +}); +app.put( + "/api/journals/:journalId/members/:userId", + auth, + async (req, res, next) => { + try { + const role = ["editor", "contributor", "viewer"].includes(req.body.role) + ? req.body.role + : null; + if (!role) return res.status(400).json({ error: "Invalid role" }); + const { rows } = await pool.query( + `UPDATE diary_journal_members m SET role=$1 FROM diary_journals j WHERE m.journal_id=$2 AND m.user_id=$3 AND j.id=m.journal_id AND j.owner_id=$4 RETURNING m.*`, + [role, req.params.journalId, req.params.userId, req.user.id], + ); + if (!rows[0]) return res.status(404).json({ error: "Member not found" }); + res.json(rows[0]); + } catch (error) { + next(error); + } + }, +); +app.delete( + "/api/journals/:journalId/members/:userId", + auth, + async (req, res, next) => { + try { + const result = await pool.query( + `DELETE FROM diary_journal_members m USING diary_journals j WHERE m.journal_id=$1 AND m.user_id=$2 AND j.id=m.journal_id AND j.owner_id=$3`, + [req.params.journalId, req.params.userId, req.user.id], + ); + if (!result.rowCount) + return res.status(404).json({ error: "Member not found" }); + res.status(204).end(); + } catch (error) { + next(error); + } + }, +); +app.post( + "/api/media", + auth, + upload.array("media", 12), + async (req, res, next) => { + try { + const files = req.files || [], + used = await storageUsed(req.user.id), + incoming = files.reduce((total, file) => total + file.size, 0), + captions = (() => { + try { + return JSON.parse(req.body.captions || "[]"); + } catch { + return []; + } + })(), + metadata = (() => { + try { + return JSON.parse(req.body.metadata || "[]"); + } catch { + return []; + } + })(); + const quota = await userStorageQuota(req.user.id); + if (used + incoming > quota) + return res + .status(413) + .json({ error: "Storage quota exceeded", used, quota }); + const saved = []; + for (const [index, file] of files.entries()) { + const key = `${req.user.id}/${crypto.randomUUID()}`; + await mediaStore.putObject(mediaBucket, key, file.buffer, file.size, { + "Content-Type": file.mimetype, + "X-Amz-Meta-Original-Name": encodeURIComponent(file.originalname), + }); + const item = { + key, + url: `/api/media/${encodeURIComponent(key)}`, + name: file.originalname, + type: file.mimetype, + size: file.size, + caption: String(captions[index] || "").slice(0, 500), + }; + if (file.mimetype.startsWith("image/")) { + const supplied = metadata[index] && typeof metadata[index] === "object" ? metadata[index] : {}, + textValue = (name, limit) => String(supplied[name] || "").slice(0, limit), + numberValue = (name) => supplied[name] !== null && supplied[name] !== "" && Number.isFinite(Number(supplied[name])) ? Number(supplied[name]) : null; + item.metadata = { + make: textValue("make", 100), + model: textValue("model", 100), + lens: textValue("lens", 140), + aperture: numberValue("aperture"), + exposure: numberValue("exposure"), + iso: numberValue("iso"), + focalLength: numberValue("focalLength"), + width: numberValue("width"), + height: numberValue("height"), + capturedAt: /^\d{4}-\d{2}-\d{2}T/.test(String(supplied.capturedAt || "")) ? String(supplied.capturedAt).slice(0, 40) : null, + }; + try { + const thumbnail = await sharp(file.buffer) + .rotate() + .resize({ + width: 640, + height: 640, + fit: "inside", + withoutEnlargement: true, + }) + .webp({ quality: 78 }) + .toBuffer(); + item.thumbKey = `${key}.thumb.webp`; + item.thumbUrl = `/api/media/${encodeURIComponent(item.thumbKey)}`; + await mediaStore.putObject( + mediaBucket, + item.thumbKey, + thumbnail, + thumbnail.length, + { + "Content-Type": "image/webp", + }, + ); + } catch { + // Preserve the original even when its format cannot be thumbnailed. + } + } + saved.push(item); + } + res.status(201).json(saved); + } catch (e) { + next(e); + } + }, +); +app.get("/api/storage", auth, async (req, res, next) => { + try { + res.json({ + used: await storageUsed(req.user.id), + quota: await userStorageQuota(req.user.id), + }); + } catch (e) { + next(e); + } +}); +app.get("/api/storage/details", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT e.id AS entry_id,e.title,item FROM diary_entries e CROSS JOIN LATERAL jsonb_array_elements(COALESCE(e.media,'[]'::jsonb)) item WHERE e.user_id=$1 AND e.deleted_at IS NULL`, + [req.user.id], + ); + const files = []; + for (const row of rows) { + const item = row.item || {}, key = item.key || ""; + let checksum = ""; + if (key) try { checksum = (await mediaStore.statObject(mediaBucket, key)).etag || ""; } catch {} + const type = String(item.type || "application/octet-stream"), category = type.startsWith("image/") ? "images" : type.startsWith("video/") ? "videos" : type.startsWith("audio/") ? "audio" : "documents"; + files.push({ entryId: +row.entry_id, entryTitle: row.title, key, thumbKey: item.thumbKey || "", name: item.name || "attachment", type, category, size: Number(item.size || 0), checksum, url: item.url || "" }); + } + const categories = { images: 0, videos: 0, audio: 0, documents: 0 }; + for (const file of files) categories[file.category] += file.size; + const grouped = new Map(); + for (const file of files) { + if (!file.checksum) continue; + const duplicateKey = `${file.checksum}:${file.size}`, group = grouped.get(duplicateKey) || []; + group.push(file); + grouped.set(duplicateKey, group); + } + res.json({ used: await storageUsed(req.user.id), quota: await userStorageQuota(req.user.id), categories, files: files.sort((a, b) => b.size - a.size), duplicates: [...grouped.values()].filter((group) => group.length > 1) }); + } catch (error) { next(error); } +}); +app.delete("/api/storage/files", auth, async (req, res, next) => { + const client = await pool.connect(); + try { + const key = String(req.body.key || ""); + if (!key.startsWith(`${req.user.id}/`)) return res.status(403).json({ error: "File ownership required" }); + await client.query("BEGIN"); + const { rows } = await client.query("SELECT id,media FROM diary_entries WHERE user_id=$1 AND media @> $2::jsonb FOR UPDATE", [req.user.id, JSON.stringify([{ key }])]); + let thumbKey = ""; + for (const row of rows) { + const matched = (row.media || []).find((item) => item.key === key); + thumbKey ||= matched?.thumbKey || ""; + await client.query("UPDATE diary_entries SET media=$1,updated_at=NOW() WHERE id=$2", [JSON.stringify((row.media || []).filter((item) => item.key !== key)), row.id]); + } + await client.query("COMMIT"); + try { await mediaStore.removeObject(mediaBucket, key); } catch {} + if (thumbKey) try { await mediaStore.removeObject(mediaBucket, thumbKey); } catch {} + res.json({ removedFromEntries: rows.length }); + } catch (error) { + await client.query("ROLLBACK"); + next(error); + } finally { client.release(); } +}); +app.get("/api/tags", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT t.tag,count(*)::int AS count FROM diary_entries e CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(e.tags,'[]'::jsonb)) AS t(tag) WHERE e.user_id=$1 AND e.deleted_at IS NULL GROUP BY t.tag ORDER BY count DESC,t.tag`, + [req.user.id], + ); + res.json(rows); + } catch (error) { next(error); } +}); +app.put("/api/tags/:tag", auth, async (req, res, next) => { + try { + const oldTag = String(req.params.tag).trim(), newTag = String(req.body.name || "").trim().replace(/^#/, "").slice(0, 80); + if (!newTag) return res.status(400).json({ error: "Tag name required" }); + const result = await pool.query( + `UPDATE diary_entries e SET tags=(SELECT jsonb_agg(DISTINCT CASE WHEN value=$1 THEN $2 ELSE value END) FROM jsonb_array_elements_text(e.tags) value),updated_at=NOW() WHERE e.user_id=$3 AND e.tags ? $1`, + [oldTag, newTag, req.user.id], + ); + res.json({ updated: result.rowCount }); + } catch (error) { next(error); } +}); +app.delete("/api/tags/:tag", auth, async (req, res, next) => { + try { + const tag = String(req.params.tag).trim(), result = await pool.query( + `UPDATE diary_entries e SET tags=COALESCE((SELECT jsonb_agg(value) FROM jsonb_array_elements_text(e.tags) value WHERE value<>$1),'[]'::jsonb),updated_at=NOW() WHERE e.user_id=$2 AND e.tags ? $1`, + [tag, req.user.id], + ); + res.json({ updated: result.rowCount }); + } catch (error) { next(error); } +}); +app.get("/api/media/*key", auth, async (req, res, next) => { + try { + const key = Array.isArray(req.params.key) + ? req.params.key.join("/") + : req.params.key; + if (!key.startsWith(`${req.user.id}/`)) { + const allowed = await pool.query( + `SELECT 1 FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members jm ON jm.journal_id=j.id AND jm.user_id=$2 CROSS JOIN LATERAL jsonb_array_elements(e.media) m WHERE (e.visibility='public' OR j.owner_id=$2 OR jm.user_id=$2) AND (m->>'key'=$1 OR m->>'thumbKey'=$1) LIMIT 1`, + [key, req.user.id], + ); + if (!allowed.rowCount) + return res.status(403).json({ error: "Media access denied" }); + } + const stat = await mediaStore.statObject(mediaBucket, key); + res.setHeader( + "Content-Type", + stat.metaData["content-type"] || "application/octet-stream", + ); + res.setHeader("Cache-Control", "private, max-age=86400"); + (await mediaStore.getObject(mediaBucket, key)).pipe(res); + } catch (e) { + if (e.code === "NoSuchKey" || e.code === "NotFound") + return res.status(404).end(); + next(e); + } +}); +async function findPublicLink(token) { + const { rows } = await pool.query( + `SELECT l.*,e.*,l.id AS link_id,l.password_hash AS link_password_hash,l.expires_at AS link_expires_at,u.display_name FROM diary_public_links l JOIN diary_entries e ON e.id=l.entry_id JOIN diary_users u ON u.id=e.user_id WHERE l.token_hash=$1 AND l.revoked_at IS NULL AND l.expires_at>NOW() AND e.deleted_at IS NULL`, + [hash(token)], + ); + return rows[0] || null; +} +async function hasPublicGrant(req, link) { + if (!link.link_password_hash) return true; + const grant = cookie(req, "field_log_share"); + if (!grant) return false; + const result = await pool.query( + "SELECT 1 FROM diary_public_sessions WHERE token_hash=$1 AND link_id=$2 AND expires_at>NOW()", + [hash(grant), link.link_id], + ); + return !!result.rowCount; +} +function sharedEntry(row, token) { + const result = entry({ ...row, owner: false }); + result.media = (result.media || []).map((item) => ({ + ...item, + url: item.key + ? `/api/public/${encodeURIComponent(token)}/media/${encodeURIComponent(item.key)}` + : item.url, + thumbUrl: item.thumbKey + ? `/api/public/${encodeURIComponent(token)}/media/${encodeURIComponent(item.thumbKey)}` + : item.thumbUrl, + })); + return result; +} +app.get("/api/public/:token", async (req, res, next) => { + try { + const link = await findPublicLink(req.params.token); + if (!link) + return res + .status(404) + .json({ error: "Share link is invalid or expired" }); + if (!(await hasPublicGrant(req, link))) + return res.status(401).json({ passwordRequired: true }); + await pool.query( + "UPDATE diary_public_links SET view_count=view_count+1,last_viewed_at=NOW() WHERE id=$1", + [link.link_id], + ); + res.json(sharedEntry(link, req.params.token)); + } catch (error) { + next(error); + } +}); +app.post("/api/public/:token/access", async (req, res, next) => { + try { + const link = await findPublicLink(req.params.token); + if (!link) + return res + .status(404) + .json({ error: "Share link is invalid or expired" }); + if ( + link.link_password_hash && + !(await verify(String(req.body.password || ""), link.link_password_hash)) + ) + return res.status(401).json({ error: "Incorrect password" }); + const grant = crypto.randomBytes(32).toString("base64url"), + expiry = new Date( + Math.min( + Date.now() + 86400000, + new Date(link.link_expires_at).getTime(), + ), + ); + await pool.query( + "INSERT INTO diary_public_sessions(token_hash,link_id,expires_at) VALUES($1,$2,$3)", + [hash(grant), link.link_id, expiry], + ); + res.setHeader( + "Set-Cookie", + `field_log_share=${grant}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.max(1, Math.floor((expiry.getTime() - Date.now()) / 1000))}${process.env.COOKIE_SECURE === "true" ? "; Secure" : ""}`, + ); + await pool.query( + "UPDATE diary_public_links SET view_count=view_count+1,last_viewed_at=NOW() WHERE id=$1", + [link.link_id], + ); + res.json(sharedEntry(link, req.params.token)); + } catch (error) { + next(error); + } +}); +app.get("/api/public/:token/media/*key", async (req, res, next) => { + try { + const link = await findPublicLink(req.params.token); + if (!link || !(await hasPublicGrant(req, link))) + return res.status(403).end(); + const key = Array.isArray(req.params.key) + ? req.params.key.join("/") + : req.params.key, + allowed = (link.media || []).some( + (item) => item.key === key || item.thumbKey === key, + ); + if (!allowed) return res.status(403).end(); + const stat = await mediaStore.statObject(mediaBucket, key); + res.setHeader( + "Content-Type", + stat.metaData["content-type"] || "application/octet-stream", + ); + res.setHeader("Cache-Control", "private, max-age=3600"); + (await mediaStore.getObject(mediaBucket, key)).pipe(res); + } catch (error) { + if (error.code === "NoSuchKey" || error.code === "NotFound") + return res.status(404).end(); + next(error); + } +}); +app.get("/api/entries/:id/public-links", auth, async (req, res, next) => { + try { + const access = await entryAccess(req.user.id, req.params.id); + if (!access?.can_manage) + return res.status(403).json({ error: "Entry management required" }); + if (access.is_encrypted) + return res + .status(409) + .json({ error: "Encrypted entries cannot have public links" }); + const { rows } = await pool.query( + "SELECT id,expires_at,revoked_at,view_count,created_at,last_viewed_at,token_ciphertext,(password_hash IS NOT NULL) AS password_protected FROM diary_public_links WHERE entry_id=$1 ORDER BY created_at DESC", + [req.params.id], + ); + res.json(rows.map(({ token_ciphertext, ...row }) => { + let url = null; + if (token_ciphertext && !row.revoked_at && new Date(row.expires_at) > new Date()) { + try { url = `/share/${decryptSecret(token_ciphertext)}`; } catch {} + } + return { ...row, url }; + })); + } catch (error) { + next(error); + } +}); +app.post("/api/entries/:id/public-links/automatic", auth, async (req, res, next) => { + try { + const access = await entryAccess(req.user.id, req.params.id), + days = Math.min(365, Math.max(1, +(req.body.days || 7))); + if (!access?.can_manage) + return res.status(403).json({ error: "Entry management required" }); + if (access.is_encrypted) + return res.status(409).json({ error: "Encrypted entries cannot have public links" }); + const existing = await pool.query( + `SELECT id,expires_at,created_at,token_ciphertext FROM diary_public_links WHERE entry_id=$1 AND revoked_at IS NULL AND expires_at>NOW() AND token_ciphertext IS NOT NULL AND password_hash IS NULL ORDER BY created_at DESC LIMIT 1`, + [req.params.id], + ); + if (existing.rows[0]) { + try { + const { token_ciphertext, ...link } = existing.rows[0]; + return res.json({ ...link, url: `/share/${decryptSecret(token_ciphertext)}`, reused: true }); + } catch {} + } + const token = crypto.randomBytes(32).toString("base64url"), + created = await pool.query( + `INSERT INTO diary_public_links(entry_id,created_by,token_hash,token_ciphertext,expires_at) VALUES($1,$2,$3,$4,NOW()+($5||' days')::interval) RETURNING id,expires_at,created_at`, + [req.params.id, req.user.id, hash(token), encryptSecret(token), days], + ); + await activity(access.journal_id, req.user.id, "created_public_link", req.params.id, { + expiresAt: created.rows[0].expires_at, + passwordProtected: false, + automatic: true, + }); + res.status(201).json({ ...created.rows[0], url: `/share/${token}`, reused: false }); + } catch (error) { + next(error); + } +}); +app.get("/api/public-links", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT l.id,l.entry_id,l.expires_at,l.revoked_at,l.view_count,l.created_at,l.last_viewed_at,(l.password_hash IS NOT NULL) AS password_protected,e.title FROM diary_public_links l JOIN diary_entries e ON e.id=l.entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE e.user_id=$1 OR j.owner_id=$1 OR m.role='editor' ORDER BY l.created_at DESC`, + [req.user.id], + ); + res.json(rows); + } catch (error) { + next(error); + } +}); +app.get("/api/blocks", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT u.id,u.username,u.display_name,b.created_at FROM diary_user_blocks b JOIN diary_users u ON u.id=b.blocked_id WHERE b.blocker_id=$1 ORDER BY b.created_at DESC", + [req.user.id], + ); + res.json(rows); + } catch (error) { next(error); } +}); +app.post("/api/blocks", auth, async (req, res, next) => { + try { + const username = String(req.body.username || "").trim().toLowerCase(), + found = await pool.query("SELECT id FROM diary_users WHERE username=$1 AND id<>$2", [username, req.user.id]); + if (!found.rows[0]) return res.status(404).json({ error: "User not found" }); + await pool.query("INSERT INTO diary_user_blocks(blocker_id,blocked_id) VALUES($1,$2) ON CONFLICT DO NOTHING", [req.user.id, found.rows[0].id]); + res.status(201).json({ ok: true }); + } catch (error) { next(error); } +}); +app.delete("/api/blocks/:id", auth, async (req, res, next) => { + try { + await pool.query("DELETE FROM diary_user_blocks WHERE blocker_id=$1 AND blocked_id=$2", [req.user.id, req.params.id]); + res.status(204).end(); + } catch (error) { next(error); } +}); +app.get("/api/comments/pending", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT c.id,c.body,c.created_at,e.id AS entry_id,e.title,u.display_name,u.username FROM diary_comments c JOIN diary_entries e ON e.id=c.entry_id JOIN diary_users u ON u.id=c.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE c.deleted_at IS NULL AND NOT c.approved AND (e.user_id=$1 OR j.owner_id=$1 OR m.role='editor') ORDER BY c.created_at DESC`, + [req.user.id], + ); + res.json(rows); + } catch (error) { next(error); } +}); +app.post("/api/comments/:id/approve", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `UPDATE diary_comments c SET approved=true FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE c.id=$1 AND e.id=c.entry_id AND (e.user_id=$2 OR j.owner_id=$2 OR m.role='editor') RETURNING c.id`, + [req.params.id, req.user.id], + ); + if (!rows[0]) return res.status(404).json({ error: "Pending comment not found" }); + res.json(rows[0]); + } catch (error) { next(error); } +}); +app.post("/api/entries/:id/public-links", auth, async (req, res, next) => { + try { + const access = await entryAccess(req.user.id, req.params.id), + days = Math.min(365, Math.max(1, +(req.body.days || 7))), + pass = String(req.body.password || ""), + token = crypto.randomBytes(32).toString("base64url"); + if (!access?.can_manage) + return res.status(403).json({ error: "Entry management required" }); + if (access.is_encrypted) + return res + .status(409) + .json({ error: "Encrypted entries cannot have public links" }); + if (pass && pass.length < 8) + return res + .status(400) + .json({ error: "Share passwords need at least 8 characters" }); + const { rows } = await pool.query( + `INSERT INTO diary_public_links(entry_id,created_by,token_hash,token_ciphertext,password_hash,expires_at) VALUES($1,$2,$3,$4,$5,NOW()+($6||' days')::interval) RETURNING id,expires_at,created_at`, + [ + req.params.id, + req.user.id, + hash(token), + encryptSecret(token), + pass ? await password(pass) : null, + days, + ], + ); + await activity( + access.journal_id, + req.user.id, + "created_public_link", + req.params.id, + { expiresAt: rows[0].expires_at, passwordProtected: !!pass }, + ); + res.status(201).json({ + ...rows[0], + token, + url: `/share/${token}`, + passwordProtected: !!pass, + }); + } catch (error) { + next(error); + } +}); +app.delete("/api/public-links/:id", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `UPDATE diary_public_links l SET revoked_at=NOW() FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE l.id=$1 AND e.id=l.entry_id AND (e.user_id=$2 OR j.owner_id=$2 OR m.role='editor') RETURNING l.id,l.entry_id,e.journal_id`, + [req.params.id, req.user.id], + ); + if (!rows[0]) + return res.status(404).json({ error: "Share link not found" }); + await activity( + rows[0].journal_id, + req.user.id, + "revoked_public_link", + rows[0].entry_id, + ); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.get("/api/entries", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT e.*,u.display_name,(e.user_id=$1 OR j.owner_id=$1 OR jm.role='editor') AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members jm ON jm.journal_id=j.id AND jm.user_id=$1 WHERE e.deleted_at IS NULL AND (e.user_id=$1 OR j.owner_id=$1 OR jm.user_id=$1 OR e.visibility='public') ORDER BY e.pinned DESC,e.entry_date DESC,e.created_at DESC`, + [req.user.id], + ); + res.json(rows.map(entry)); + } catch (e) { + next(e); + } +}); +app.get("/api/entries/:id", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT e.*,u.display_name,(e.user_id=$1 OR j.owner_id=$1 OR jm.role='editor') AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members jm ON jm.journal_id=j.id AND jm.user_id=$1 WHERE e.id=$2 AND e.deleted_at IS NULL AND (e.user_id=$1 OR j.owner_id=$1 OR jm.user_id=$1 OR e.visibility='public')`, + [req.user.id, req.params.id], + ); + if (!rows[0]) return res.status(404).json({ error: "Entry not found" }); + res.json(entry(rows[0])); + } catch (error) { + next(error); + } +}); +app.get("/api/search", auth, async (req, res, next) => { + try { + const values = [req.user.id], + clauses = [ + "e.deleted_at IS NULL", + "(e.user_id=$1 OR j.owner_id=$1 OR jm.user_id=$1 OR e.visibility='public')", + ], + add = (sql, value) => { + values.push(value); + clauses.push(sql.replace("?", `$${values.length}`)); + }, + q = String(req.query.q || "") + .trim() + .slice(0, 500); + if (q) { + values.push(q); + clauses.push( + `(to_tsvector('english'::regconfig,coalesce(e.title,'')||' '||coalesce(e.mood,'')||' '||coalesce(e.location_name,'')) || jsonb_to_tsvector('english'::regconfig,e.body,'["string"]'::jsonb) || jsonb_to_tsvector('english'::regconfig,e.tags,'["string"]'::jsonb)) @@ websearch_to_tsquery('english'::regconfig,$${values.length})`, + ); + } + if (req.query.tag) + add("e.tags @> to_jsonb(ARRAY[?]::text[])", String(req.query.tag)); + if (req.query.mood) add("lower(e.mood)=lower(?)", String(req.query.mood)); + if (req.query.from) add("e.entry_date>=?::date", String(req.query.from)); + if (req.query.to) add("e.entry_date<=?::date", String(req.query.to)); + if (req.query.media === "true") + clauses.push("jsonb_array_length(e.media)>0"); + if (req.query.located === "true") + clauses.push("e.latitude IS NOT NULL AND e.longitude IS NOT NULL"); + if (req.query.owner === "mine") clauses.push("e.user_id=$1"); + if (req.query.visibility === "private") + clauses.push("e.user_id=$1 AND e.visibility='private'"); + if (req.query.visibility === "public") + clauses.push("e.visibility='public'"); + const { rows } = await pool.query( + `SELECT e.*,u.display_name,(e.user_id=$1 OR j.owner_id=$1 OR jm.role='editor') AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members jm ON jm.journal_id=j.id AND jm.user_id=$1 WHERE ${clauses.join(" AND ")} ORDER BY e.pinned DESC,e.entry_date DESC,e.created_at DESC LIMIT 500`, + values, + ); + res.json(rows.map(entry)); + } catch (e) { + next(e); + } +}); +app.get("/api/entries/:id/social", auth, async (req, res, next) => { + try { + const access = await entryAccess(req.user.id, req.params.id); + if (!access) return res.status(404).json({ error: "Entry not found" }); + const comments = await pool.query( + `SELECT c.id,c.parent_id,CASE WHEN c.deleted_at IS NULL THEN c.body ELSE '' END AS body,c.created_at,c.updated_at,c.approved,c.deleted_at IS NOT NULL AS deleted,CASE WHEN c.deleted_at IS NULL THEN u.id END AS user_id,CASE WHEN c.deleted_at IS NULL THEN u.username ELSE '' END AS username,CASE WHEN c.deleted_at IS NULL THEN u.display_name ELSE 'Deleted comment' END AS display_name,(c.deleted_at IS NULL AND (c.user_id=$2 OR $3::boolean)) AS can_delete FROM diary_comments c JOIN diary_users u ON u.id=c.user_id WHERE c.entry_id=$1 AND (c.deleted_at IS NOT NULL OR c.approved OR c.user_id=$2 OR $3::boolean) ORDER BY c.created_at`, + [req.params.id, req.user.id, access.can_manage], + ); + const reactions = await pool.query( + `SELECT r.id,r.comment_id,r.emoji,u.display_name,(r.user_id=$2) AS mine FROM diary_reactions r JOIN diary_users u ON u.id=r.user_id LEFT JOIN diary_comments c ON c.id=r.comment_id WHERE r.entry_id=$1 AND (r.comment_id IS NULL OR c.deleted_at IS NULL) ORDER BY r.created_at`, + [req.params.id, req.user.id], + ); + res.json({ comments: comments.rows, reactions: reactions.rows }); + } catch (error) { + next(error); + } +}); +app.post("/api/entries/:id/comments", auth, async (req, res, next) => { + try { + const access = await entryAccess(req.user.id, req.params.id), + body = String(req.body.body || "") + .trim() + .slice(0, 5000), + parentId = req.body.parentId || null; + if (!access) return res.status(404).json({ error: "Entry not found" }); + if (!body) return res.status(400).json({ error: "Comment required" }); + const ownerPreferences = await pool.query( + "SELECT preferences FROM diary_users WHERE id=$1", + [access.user_id], + ); + const commentPolicy = { + ...defaultUserPreferences, + ...(ownerPreferences.rows[0]?.preferences || {}), + }.commentPolicy; + const ownsEntry = +access.user_id === +req.user.id; + const blocked = await pool.query( + "SELECT 1 FROM diary_user_blocks WHERE (blocker_id=$1 AND blocked_id=$2) OR (blocker_id=$2 AND blocked_id=$1) LIMIT 1", + [access.user_id, req.user.id], + ); + if (blocked.rowCount && !ownsEntry) + return res.status(403).json({ error: "Comments are unavailable between these accounts" }); + if (commentPolicy === "nobody" && !ownsEntry) + return res.status(403).json({ error: "Comments are disabled for this entry" }); + if (commentPolicy === "members" && !ownsEntry && !access.role) + return res.status(403).json({ error: "Comments are limited to journal members" }); + if (parentId) { + const parent = await pool.query( + "SELECT 1 FROM diary_comments WHERE id=$1 AND entry_id=$2 AND deleted_at IS NULL", + [parentId, req.params.id], + ); + if (!parent.rowCount) + return res.status(400).json({ error: "Invalid parent comment" }); + } + const approved = ownsEntry || access.can_manage || !ownerPreferences.rows[0]?.preferences?.approveComments, + { rows } = await pool.query( + `INSERT INTO diary_comments(entry_id,user_id,parent_id,body,approved) VALUES($1,$2,$3,$4,$5) RETURNING *`, + [req.params.id, req.user.id, parentId, body, approved], + ); + if (!approved) return res.status(202).json({ ...rows[0], pending: true }); + await pool.query( + `INSERT INTO diary_notifications(user_id,actor_id,entry_id,comment_id,type) SELECT e.user_id,$1,e.id,$3,'comment' FROM diary_entries e JOIN diary_users u ON u.id=e.user_id WHERE e.id=$2 AND e.user_id<>$1 AND COALESCE((u.preferences->>'notifyComments')::boolean,true)`, + [req.user.id, req.params.id, rows[0].id], + ); + if (parentId) + await pool.query( + `INSERT INTO diary_notifications(user_id,actor_id,entry_id,comment_id,type) SELECT c.user_id,$1,c.entry_id,$3,'reply' FROM diary_comments c JOIN diary_users u ON u.id=c.user_id WHERE c.id=$2 AND c.user_id<>$1 AND COALESCE((u.preferences->>'notifyReplies')::boolean,true) AND c.user_id<>(SELECT user_id FROM diary_entries WHERE id=c.entry_id)`, + [req.user.id, parentId, rows[0].id], + ); + const usernames = [ + ...new Set( + [...body.matchAll(/(?:^|\s)@([a-z0-9_-]{3,30})/gi)].map((match) => + match[1].toLowerCase(), + ), + ), + ]; + if (usernames.length) + await pool.query( + `INSERT INTO diary_notifications(user_id,actor_id,entry_id,comment_id,type) SELECT u.id,$1,$2,$3,'mention' FROM diary_users u WHERE u.username=ANY($4) AND u.id<>$1 AND COALESCE((u.preferences->>'notifyMentions')::boolean,true) AND NOT EXISTS(SELECT 1 FROM diary_user_blocks b WHERE (b.blocker_id=u.id AND b.blocked_id=$1) OR (b.blocker_id=$1 AND b.blocked_id=u.id)) AND (EXISTS(SELECT 1 FROM diary_journals j WHERE j.id=$5 AND j.owner_id=u.id) OR EXISTS(SELECT 1 FROM diary_journal_members m WHERE m.journal_id=$5 AND m.user_id=u.id))`, + [req.user.id, req.params.id, rows[0].id, usernames, access.journal_id], + ); + await activity(access.journal_id, req.user.id, "commented", req.params.id, { + commentId: +rows[0].id, + }); + res.status(201).json(rows[0]); + } catch (error) { + next(error); + } +}); +app.delete("/api/comments/:id", auth, async (req, res, next) => { + const client = await pool.connect(); + try { + const found = await client.query( + "SELECT c.*,e.journal_id FROM diary_comments c JOIN diary_entries e ON e.id=c.entry_id WHERE c.id=$1", + [req.params.id], + ), + comment = found.rows[0]; + if (!comment) return res.status(404).json({ error: "Comment not found" }); + const access = await entryAccess(req.user.id, comment.entry_id); + if (!access || (+comment.user_id !== +req.user.id && !access.can_manage)) + return res.status(403).json({ error: "Not allowed" }); + await client.query("BEGIN"); + const children = await client.query("SELECT 1 FROM diary_comments WHERE parent_id=$1 LIMIT 1", [req.params.id]); + if (children.rowCount) { + await client.query("UPDATE diary_comments SET body='',deleted_at=NOW(),updated_at=NOW() WHERE id=$1", [req.params.id]); + await client.query("DELETE FROM diary_reactions WHERE comment_id=$1", [req.params.id]); + } else { + await client.query("DELETE FROM diary_comments WHERE id=$1", [req.params.id]); + let ancestorId = comment.parent_id; + while (ancestorId) { + const ancestor = await client.query("SELECT id,parent_id,deleted_at,EXISTS(SELECT 1 FROM diary_comments child WHERE child.parent_id=diary_comments.id) AS has_children FROM diary_comments WHERE id=$1 FOR UPDATE", [ancestorId]); + if (!ancestor.rowCount || !ancestor.rows[0].deleted_at || ancestor.rows[0].has_children) break; + await client.query("DELETE FROM diary_comments WHERE id=$1", [ancestorId]); + ancestorId = ancestor.rows[0].parent_id; + } + } + await client.query("COMMIT"); + await activity( + comment.journal_id, + req.user.id, + "deleted_comment", + comment.entry_id, + ); + res.json({ deleted: true, tombstoned: children.rowCount > 0 }); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + next(error); + } finally { + client.release(); + } +}); +app.post("/api/entries/:id/reactions", auth, async (req, res, next) => { + try { + const access = await entryAccess(req.user.id, req.params.id), + emoji = String(req.body.emoji || "").slice(0, 16), + commentId = req.body.commentId || null; + if (!access) return res.status(404).json({ error: "Entry not found" }); + if (!emoji) return res.status(400).json({ error: "Reaction required" }); + const blocked = await pool.query( + "SELECT 1 FROM diary_user_blocks WHERE (blocker_id=$1 AND blocked_id=$2) OR (blocker_id=$2 AND blocked_id=$1) LIMIT 1", + [access.user_id, req.user.id], + ); + if (blocked.rowCount && +access.user_id !== +req.user.id) + return res.status(403).json({ error: "Reactions are unavailable between these accounts" }); + if (commentId) { + const comment = await pool.query( + "SELECT 1 FROM diary_comments WHERE id=$1 AND entry_id=$2 AND deleted_at IS NULL", + [commentId, req.params.id], + ); + if (!comment.rowCount) + return res.status(400).json({ error: "Invalid comment" }); + } + const removed = await pool.query( + "DELETE FROM diary_reactions WHERE entry_id=$1 AND comment_id IS NOT DISTINCT FROM $2::bigint AND user_id=$3 AND emoji=$4", + [req.params.id, commentId, req.user.id, emoji], + ); + if (!removed.rowCount) + await pool.query( + "INSERT INTO diary_reactions(entry_id,comment_id,user_id,emoji) VALUES($1,$2,$3,$4)", + [req.params.id, commentId, req.user.id, emoji], + ); + await activity( + access.journal_id, + req.user.id, + removed.rowCount ? "removed_reaction" : "reacted", + req.params.id, + { emoji, commentId }, + ); + res.json({ active: !removed.rowCount }); + } catch (error) { + next(error); + } +}); +app.get("/api/journals/:id/activity", auth, async (req, res, next) => { + try { + const access = await pool.query( + `SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=$1 AND (j.owner_id=$2 OR m.user_id=$2)`, + [req.params.id, req.user.id], + ); + if (!access.rowCount) + return res.status(403).json({ error: "Journal access required" }); + const { rows } = await pool.query( + `SELECT a.id,a.action,a.metadata,a.created_at,u.display_name,e.title AS entry_title FROM diary_activity a LEFT JOIN diary_users u ON u.id=a.actor_id LEFT JOIN diary_entries e ON e.id=a.entry_id WHERE a.journal_id=$1 ORDER BY a.created_at DESC LIMIT 100`, + [req.params.id], + ); + res.json(rows); + } catch (error) { + next(error); + } +}); +app.get("/api/notifications", auth, async (req, res, next) => { + try { + const preferenceResult = await pool.query("SELECT preferences FROM diary_users WHERE id=$1", [req.user.id]), + preferences = { ...defaultUserPreferences, ...(preferenceResult.rows[0]?.preferences || {}) }; + if (!preferences.notificationInApp && req.query.channel !== "browser") return res.json([]); + const { rows } = await pool.query( + `SELECT n.id,n.type,n.read_at,n.created_at,n.entry_id,u.display_name AS actor,e.title AS entry_title FROM diary_notifications n LEFT JOIN diary_users u ON u.id=n.actor_id LEFT JOIN diary_entries e ON e.id=n.entry_id WHERE n.user_id=$1 ${req.query.unread === "1" ? "AND n.read_at IS NULL" : ""} ORDER BY n.created_at DESC LIMIT 100`, + [req.user.id], + ); + res.json(rows); + } catch (error) { + next(error); + } +}); +app.post("/api/notifications/read", auth, async (req, res, next) => { + try { + await pool.query( + "UPDATE diary_notifications SET read_at=NOW() WHERE user_id=$1 AND read_at IS NULL", + [req.user.id], + ); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.get("/api/knowledge", auth, async (req, res, next) => { + try { + const accessible = await pool.query( + `SELECT DISTINCT e.id,e.journal_id,e.title,e.entry_date,u.display_name AS author FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE e.deleted_at IS NULL AND (e.user_id=$1 OR j.owner_id=$1 OR m.user_id=$1 OR e.visibility='public') ORDER BY e.entry_date DESC LIMIT 1000`, + [req.user.id], + ); + const ids = accessible.rows.map((row) => row.id); + if (!ids.length) + return res.json({ entries: [], links: [], people: [], collections: [] }); + const [links, people, collections] = await Promise.all([ + pool.query( + `SELECT l.id,l.source_entry_id,l.target_entry_id,l.label FROM diary_entry_links l WHERE l.source_entry_id=ANY($1::bigint[]) AND l.target_entry_id=ANY($1::bigint[])`, + [ids], + ), + pool.query( + `SELECT p.id,p.name,p.notes,p.owner_id,COALESCE(array_agg(ep.entry_id) FILTER (WHERE ep.entry_id=ANY($2::bigint[])),'{}') AS entry_ids FROM diary_people p LEFT JOIN diary_entry_people ep ON ep.person_id=p.id WHERE p.owner_id=$1 OR ep.entry_id=ANY($2::bigint[]) GROUP BY p.id ORDER BY p.name`, + [req.user.id, ids], + ), + pool.query( + `SELECT c.id,c.journal_id,c.name,c.color,c.description,c.created_by,COALESCE(array_agg(ce.entry_id) FILTER (WHERE ce.entry_id=ANY($2::bigint[])),'{}') AS entry_ids FROM diary_collections c LEFT JOIN diary_collection_entries ce ON ce.collection_id=c.id JOIN diary_journals j ON j.id=c.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE j.owner_id=$1 OR m.user_id=$1 GROUP BY c.id ORDER BY c.name`, + [req.user.id, ids], + ), + ]); + res.json({ + entries: accessible.rows.map((row) => ({ + ...row, + id: +row.id, + journal_id: +row.journal_id, + })), + links: links.rows.map((row) => ({ + id: +row.id, + sourceId: +row.source_entry_id, + targetId: +row.target_entry_id, + label: row.label, + })), + people: people.rows.map((row) => ({ + id: +row.id, + name: row.name, + notes: row.notes, + ownerId: +row.owner_id, + entryIds: row.entry_ids.map(Number), + })), + collections: collections.rows.map((row) => ({ + id: +row.id, + journalId: +row.journal_id, + name: row.name, + color: row.color, + description: row.description, + createdBy: +row.created_by, + entryIds: row.entry_ids.map(Number), + })), + }); + } catch (error) { + next(error); + } +}); +app.post("/api/people", auth, async (req, res, next) => { + try { + const name = String(req.body.name || "") + .trim() + .slice(0, 100); + if (!name) return res.status(400).json({ error: "Person name required" }); + const { rows } = await pool.query( + "INSERT INTO diary_people(owner_id,name,notes) VALUES($1,$2,$3) RETURNING *", + [req.user.id, name, String(req.body.notes || "").slice(0, 1000)], + ); + res.status(201).json(rows[0]); + } catch (error) { + if (error.code === "23505") + return res.status(409).json({ error: "That person already exists" }); + next(error); + } +}); +app.put("/api/people/:id", auth, async (req, res, next) => { + try { + const name = String(req.body.name || "") + .trim() + .slice(0, 100), + { rows } = await pool.query( + "UPDATE diary_people SET name=$1,notes=$2 WHERE id=$3 AND owner_id=$4 RETURNING *", + [ + name, + String(req.body.notes || "").slice(0, 1000), + req.params.id, + req.user.id, + ], + ); + if (!rows[0]) return res.status(404).json({ error: "Person not found" }); + res.json(rows[0]); + } catch (error) { + next(error); + } +}); +app.delete("/api/people/:id", auth, async (req, res, next) => { + try { + const result = await pool.query( + "DELETE FROM diary_people WHERE id=$1 AND owner_id=$2", + [req.params.id, req.user.id], + ); + if (!result.rowCount) + return res.status(404).json({ error: "Person not found" }); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.post("/api/entries/:id/people/:personId", auth, async (req, res, next) => { + try { + const access = await entryAccess(req.user.id, req.params.id); + if (!access?.can_manage) + return res.status(403).json({ error: "Entry management required" }); + const person = await pool.query( + "SELECT 1 FROM diary_people WHERE id=$1 AND (owner_id=$2 OR EXISTS(SELECT 1 FROM diary_entry_people ep JOIN diary_entries e ON e.id=ep.entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE ep.person_id=$1 AND (j.owner_id=$2 OR m.user_id=$2)))", + [req.params.personId, req.user.id], + ); + if (!person.rowCount) + return res.status(404).json({ error: "Person not found" }); + const removed = await pool.query( + "DELETE FROM diary_entry_people WHERE entry_id=$1 AND person_id=$2", + [req.params.id, req.params.personId], + ); + if (!removed.rowCount) + await pool.query( + "INSERT INTO diary_entry_people(entry_id,person_id,created_by) VALUES($1,$2,$3)", + [req.params.id, req.params.personId, req.user.id], + ); + await activity( + access.journal_id, + req.user.id, + removed.rowCount ? "unlinked_person" : "linked_person", + req.params.id, + { personId: +req.params.personId }, + ); + res.json({ active: !removed.rowCount }); + } catch (error) { + next(error); + } +}); +app.post("/api/entries/:id/links", auth, async (req, res, next) => { + try { + const access = await entryAccess(req.user.id, req.params.id), + target = await entryAccess(req.user.id, req.body.targetId), + label = String(req.body.label || "related") + .trim() + .slice(0, 60); + if (!access?.can_manage) + return res.status(403).json({ error: "Entry management required" }); + if (!target || +req.params.id === +req.body.targetId) + return res + .status(400) + .json({ error: "Valid different target entry required" }); + const { rows } = await pool.query( + "INSERT INTO diary_entry_links(source_entry_id,target_entry_id,created_by,label) VALUES($1,$2,$3,$4) ON CONFLICT(source_entry_id,target_entry_id) DO UPDATE SET label=EXCLUDED.label RETURNING *", + [req.params.id, req.body.targetId, req.user.id, label || "related"], + ); + await activity( + access.journal_id, + req.user.id, + "linked_entry", + req.params.id, + { targetId: +req.body.targetId, label }, + ); + res.status(201).json(rows[0]); + } catch (error) { + next(error); + } +}); +app.get("/api/entries/:id/links", auth, async (req, res, next) => { + try { + const access = await entryAccess(req.user.id, req.params.id); + if (!access) return res.status(404).json({ error: "Entry not found" }); + const visibleLinkSql = `(e.user_id=$2 OR j.owner_id=$2 OR m.user_id=$2 OR e.visibility='public')`, + outbound = await pool.query( + `SELECT l.id,l.label,e.id AS entry_id,e.title,e.entry_date FROM diary_entry_links l JOIN diary_entries e ON e.id=l.target_entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE l.source_entry_id=$1 AND ${visibleLinkSql} ORDER BY e.entry_date DESC`, + [req.params.id, req.user.id], + ), + backlinks = await pool.query( + `SELECT l.id,l.label,e.id AS entry_id,e.title,e.entry_date FROM diary_entry_links l JOIN diary_entries e ON e.id=l.source_entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE l.target_entry_id=$1 AND ${visibleLinkSql} ORDER BY e.entry_date DESC`, + [req.params.id, req.user.id], + ); + res.json({ outbound: outbound.rows, backlinks: backlinks.rows }); + } catch (error) { + next(error); + } +}); +app.delete("/api/entry-links/:id", auth, async (req, res, next) => { + try { + const found = await pool.query( + "SELECT * FROM diary_entry_links WHERE id=$1", + [req.params.id], + ), + link = found.rows[0], + access = link && (await entryAccess(req.user.id, link.source_entry_id)); + if (!access?.can_manage) + return res.status(403).json({ error: "Entry management required" }); + await pool.query("DELETE FROM diary_entry_links WHERE id=$1", [ + req.params.id, + ]); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.post("/api/collections", auth, async (req, res, next) => { + try { + const access = await writableJournal(req.user.id, req.body.journalId, [ + "owner", + "editor", + ]), + name = String(req.body.name || "") + .trim() + .slice(0, 100), + color = /^#[0-9a-f]{6}$/i.test(req.body.color) + ? req.body.color + : "#bb9af7"; + if (!access) + return res.status(403).json({ error: "Journal editor required" }); + if (!name) + return res.status(400).json({ error: "Collection name required" }); + const { rows } = await pool.query( + "INSERT INTO diary_collections(journal_id,name,color,description,created_by) VALUES($1,$2,$3,$4,$5) RETURNING *", + [ + access.id, + name, + color, + String(req.body.description || "").slice(0, 500), + req.user.id, + ], + ); + res.status(201).json(rows[0]); + } catch (error) { + if (error.code === "23505") + return res.status(409).json({ error: "That collection already exists" }); + next(error); + } +}); +app.post( + "/api/collections/:id/entries/:entryId", + auth, + async (req, res, next) => { + try { + const collection = await pool.query( + "SELECT * FROM diary_collections WHERE id=$1", + [req.params.id], + ), + item = collection.rows[0], + access = item && (await writableJournal(req.user.id, item.journal_id)), + entryPermission = await entryAccess(req.user.id, req.params.entryId); + if ( + !access || + !entryPermission?.can_manage || + +entryPermission.journal_id !== +item.journal_id + ) + return res + .status(403) + .json({ error: "Collection and entry access required" }); + const removed = await pool.query( + "DELETE FROM diary_collection_entries WHERE collection_id=$1 AND entry_id=$2", + [req.params.id, req.params.entryId], + ); + if (!removed.rowCount) + await pool.query( + "INSERT INTO diary_collection_entries(collection_id,entry_id,added_by) VALUES($1,$2,$3)", + [req.params.id, req.params.entryId, req.user.id], + ); + res.json({ active: !removed.rowCount }); + } catch (error) { + next(error); + } + }, +); +app.delete("/api/collections/:id", auth, async (req, res, next) => { + try { + const result = await pool.query( + "DELETE FROM diary_collections c USING diary_journals j WHERE c.id=$1 AND j.id=c.journal_id AND (j.owner_id=$2 OR EXISTS(SELECT 1 FROM diary_journal_members m WHERE m.journal_id=j.id AND m.user_id=$2 AND m.role='editor'))", + [req.params.id, req.user.id], + ); + if (!result.rowCount) + return res.status(404).json({ error: "Collection not found" }); + res.status(204).end(); + } catch (error) { + next(error); + } +}); +app.post("/api/entries", auth, async (req, res, next) => { + try { + const x = req.body, + encrypted = x.encrypted === true, + journalAccess = await writableJournal(req.user.id, x.journalId), + journalId = journalAccess?.id; + if ( + !x.date || + (encrypted + ? !x.encryptedPayload?.ciphertext || !x.encryptedPayload?.iv + : !x.body?.length && !x.media?.length) + ) + return res.status(400).json({ error: "Date and content required" }); + if (!journalId) + return res.status(400).json({ error: "Valid journal required" }); + const { rows } = await pool.query( + `INSERT INTO diary_entries(user_id,entry_date,entry_time,title,body,tags,media,link,visibility,mood,location_name,latitude,longitude,pinned,journal_id,is_encrypted,encrypted_payload,reminder_at,weather) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) RETURNING *`, + [ + req.user.id, + x.date, + x.time || null, + encrypted ? "Encrypted entry" : x.title || "Untitled", + JSON.stringify(encrypted ? [] : x.body || []), + JSON.stringify(encrypted ? [] : x.tags || []), + JSON.stringify(x.media || []), + encrypted ? "" : x.link || "", + encrypted + ? "private" + : x.visibility === "public" + ? "public" + : "private", + encrypted ? "" : x.mood || "", + encrypted ? "" : x.location || "", + encrypted ? null : x.latitude || null, + encrypted ? null : x.longitude || null, + !!x.pinned, + journalId, + encrypted, + encrypted ? JSON.stringify(x.encryptedPayload) : null, + x.reminderAt || null, + x.weather || null, + ], + ); + await activity(journalId, req.user.id, "created_entry", rows[0].id, { + title: rows[0].title, + }); + await notifyJournalMembers(journalId, req.user.id, rows[0].id, "shared_entry_created"); + res + .status(201) + .json(entry({ ...rows[0], display_name: req.user.display_name })); + } catch (e) { + next(e); + } +}); +app.put("/api/entries/:id", auth, async (req, res, next) => { + try { + const x = req.body, + encrypted = x.encrypted === true, + currentEntry = await pool.query( + "SELECT user_id FROM diary_entries WHERE id=$1", + [req.params.id], + ), + ownsEntry = +currentEntry.rows[0]?.user_id === +req.user.id, + journalAccess = await writableJournal( + req.user.id, + x.journalId, + ownsEntry ? ["owner", "editor", "contributor"] : ["owner", "editor"], + ), + journalId = journalAccess?.id; + if ( + !x.date || + (encrypted + ? !x.encryptedPayload?.ciphertext || !x.encryptedPayload?.iv + : !x.body?.length && !x.media?.length) + ) + return res.status(400).json({ error: "Date and content required" }); + if (!journalId) + return res.status(400).json({ error: "Valid journal required" }); + await pool.query( + `INSERT INTO diary_entry_revisions(entry_id,user_id,snapshot) SELECT e.id,$2,to_jsonb(e) FROM diary_entries e JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE e.id=$1 AND (e.user_id=$2 OR j.owner_id=$2 OR m.role='editor')`, + [req.params.id, req.user.id], + ); + const { rows } = await pool.query( + `UPDATE diary_entries e SET entry_date=$1,entry_time=$20,title=$2,body=$3,tags=$4,media=$5,link=$6,visibility=$7,mood=$8,location_name=$9,latitude=$10,longitude=$11,pinned=$12,journal_id=$13,is_encrypted=$16,encrypted_payload=$17,reminder_at=$18,weather=$19,updated_at=NOW() WHERE e.id=$14 AND (e.user_id=$15 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$15 WHERE j.id=e.journal_id AND (j.owner_id=$15 OR m.role='editor'))) RETURNING e.*`, + [ + x.date, + encrypted ? "Encrypted entry" : x.title || "Untitled", + JSON.stringify(encrypted ? [] : x.body || []), + JSON.stringify(encrypted ? [] : x.tags || []), + JSON.stringify(x.media || []), + encrypted ? "" : x.link || "", + encrypted + ? "private" + : x.visibility === "public" + ? "public" + : "private", + encrypted ? "" : x.mood || "", + encrypted ? "" : x.location || "", + encrypted ? null : x.latitude || null, + encrypted ? null : x.longitude || null, + !!x.pinned, + journalId, + req.params.id, + req.user.id, + encrypted, + encrypted ? JSON.stringify(x.encryptedPayload) : null, + x.reminderAt || null, + x.weather || null, + x.time || null, + ], + ); + if (!rows[0]) return res.status(404).json({ error: "Entry not found" }); + await activity( + rows[0].journal_id, + req.user.id, + "updated_entry", + rows[0].id, + { + title: rows[0].title, + }, + ); + await notifyJournalMembers(rows[0].journal_id, req.user.id, rows[0].id, "shared_entry_updated"); + res.json(entry({ ...rows[0], display_name: req.user.display_name })); + } catch (e) { + next(e); + } +}); +app.delete("/api/entries/:id", auth, async (req, res, next) => { + try { + const x = await pool.query( + `UPDATE diary_entries e SET deleted_at=NOW(),updated_at=NOW() WHERE e.id=$1 AND e.deleted_at IS NULL AND (e.user_id=$2 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=e.journal_id AND (j.owner_id=$2 OR m.role='editor'))) RETURNING e.journal_id,e.title`, + [req.params.id, req.user.id], + ); + if (!x.rowCount) return res.status(404).json({ error: "Not found" }); + await activity( + x.rows[0].journal_id, + req.user.id, + "trashed_entry", + req.params.id, + { + title: x.rows[0].title, + }, + ); + res.status(204).end(); + } catch (e) { + next(e); + } +}); +app.get("/api/trash", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT e.*,u.display_name,true AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$1 WHERE e.deleted_at IS NOT NULL AND (e.user_id=$1 OR j.owner_id=$1 OR m.role='editor') ORDER BY e.deleted_at DESC`, + [req.user.id], + ); + res.json(rows.map(entry)); + } catch (e) { + next(e); + } +}); +app.post("/api/entries/:id/restore", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `UPDATE diary_entries e SET deleted_at=NULL,updated_at=NOW() WHERE e.id=$1 AND (e.user_id=$2 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=e.journal_id AND (j.owner_id=$2 OR m.role='editor'))) RETURNING e.*`, + [req.params.id, req.user.id], + ); + rows[0] + ? res.json(entry({ ...rows[0], display_name: req.user.display_name })) + : res.status(404).json({ error: "Entry not found" }); + } catch (e) { + next(e); + } +}); +app.delete("/api/entries/:id/permanent", auth, async (req, res, next) => { + try { + const x = await pool.query( + `DELETE FROM diary_entries e WHERE e.id=$1 AND e.deleted_at IS NOT NULL AND (e.user_id=$2 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=e.journal_id AND (j.owner_id=$2 OR m.role='editor')))`, + [req.params.id, req.user.id], + ); + x.rowCount + ? res.status(204).end() + : res.status(404).json({ error: "Entry not found" }); + } catch (e) { + next(e); + } +}); +app.get("/api/entries/:id/revisions", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT r.id,r.created_at,r.snapshot->>'title' AS title FROM diary_entry_revisions r JOIN diary_entries e ON e.id=r.entry_id JOIN diary_journals j ON j.id=e.journal_id LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE r.entry_id=$1 AND (e.user_id=$2 OR j.owner_id=$2 OR m.role='editor') ORDER BY r.created_at DESC`, + [req.params.id, req.user.id], + ); + res.json(rows); + } catch (e) { + next(e); + } +}); +app.post( + "/api/entries/:id/revisions/:revisionId/restore", + auth, + async (req, res, next) => { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const current = await client.query( + `SELECT e.* FROM diary_entries e WHERE e.id=$1 AND (e.user_id=$2 OR EXISTS(SELECT 1 FROM diary_journals j LEFT JOIN diary_journal_members m ON m.journal_id=j.id AND m.user_id=$2 WHERE j.id=e.journal_id AND (j.owner_id=$2 OR m.role='editor'))) FOR UPDATE`, + [req.params.id, req.user.id], + ); + if (!current.rows[0]) { + await client.query("ROLLBACK"); + return res.status(404).json({ error: "Entry not found" }); + } + const revision = await client.query( + "SELECT snapshot FROM diary_entry_revisions WHERE id=$1 AND entry_id=$2", + [req.params.revisionId, req.params.id], + ); + if (!revision.rows[0]) { + await client.query("ROLLBACK"); + return res.status(404).json({ error: "Revision not found" }); + } + await client.query( + "INSERT INTO diary_entry_revisions(entry_id,user_id,snapshot) VALUES($1,$2,$3)", + [req.params.id, req.user.id, current.rows[0]], + ); + const s = revision.rows[0].snapshot; + const { rows } = await client.query( + `UPDATE diary_entries SET entry_date=$1,title=$2,body=$3,tags=$4,media=$5,link=$6,visibility=$7,mood=$8,location_name=$9,latitude=$10,longitude=$11,pinned=$12,journal_id=$13,deleted_at=NULL,updated_at=NOW() WHERE id=$14 RETURNING *`, + [ + s.entry_date, + s.title, + s.body, + s.tags, + s.media, + s.link, + s.visibility, + s.mood, + s.location_name, + s.latitude, + s.longitude, + s.pinned, + s.journal_id || current.rows[0].journal_id, + req.params.id, + ], + ); + await client.query("COMMIT"); + res.json(entry({ ...rows[0], display_name: req.user.display_name })); + } catch (e) { + await client.query("ROLLBACK"); + next(e); + } finally { + client.release(); + } + }, +); +async function ownedEntries(userId) { + const { rows } = await pool.query( + "SELECT e.*,u.display_name,true AS owner FROM diary_entries e JOIN diary_users u ON u.id=e.user_id WHERE e.user_id=$1 AND e.deleted_at IS NULL ORDER BY e.entry_date DESC,e.created_at DESC", + [userId], + ); + return rows; +} +async function completeUserExport(userId) { + const queries = { + profile: + "SELECT id,username,email,display_name,bio,email_verified_at,two_factor_enabled,is_admin,disabled_at,self_deactivated,deletion_requested_at,deletion_scheduled_for,created_at FROM diary_users WHERE id=$1", + journals: "SELECT * FROM diary_journals WHERE owner_id=$1 ORDER BY id", + memberships: + "SELECT m.*,j.name AS journal_name,j.owner_id FROM diary_journal_members m JOIN diary_journals j ON j.id=m.journal_id WHERE m.user_id=$1 ORDER BY m.journal_id", + invitations: + "SELECT id,journal_id,email,role,expires_at,accepted_at,created_at FROM diary_journal_invitations WHERE invited_by=$1 ORDER BY id", + entries: "SELECT * FROM diary_entries WHERE user_id=$1 ORDER BY id", + revisions: + "SELECT * FROM diary_entry_revisions WHERE user_id=$1 ORDER BY id", + comments: "SELECT * FROM diary_comments WHERE user_id=$1 ORDER BY id", + reactions: "SELECT * FROM diary_reactions WHERE user_id=$1 ORDER BY id", + activity: "SELECT * FROM diary_activity WHERE actor_id=$1 ORDER BY id", + notifications: + "SELECT * FROM diary_notifications WHERE user_id=$1 ORDER BY id", + publicLinks: + "SELECT id,entry_id,expires_at,revoked_at,view_count,created_at,last_viewed_at,(password_hash IS NOT NULL) AS password_protected FROM diary_public_links WHERE created_by=$1 ORDER BY id", + entryLinks: + "SELECT * FROM diary_entry_links WHERE created_by=$1 ORDER BY id", + people: "SELECT * FROM diary_people WHERE owner_id=$1 ORDER BY id", + entryPeople: + "SELECT * FROM diary_entry_people WHERE created_by=$1 ORDER BY entry_id,person_id", + collections: + "SELECT * FROM diary_collections WHERE created_by=$1 ORDER BY id", + collectionEntries: + "SELECT * FROM diary_collection_entries WHERE added_by=$1 ORDER BY collection_id,entry_id", + passkeys: + "SELECT id,name,device_type,backed_up,transports,created_at,last_used_at FROM diary_passkeys WHERE user_id=$1 ORDER BY id", + sessions: + "SELECT id,created_at,last_seen_at,ip_address,user_agent,expires_at FROM diary_sessions WHERE user_id=$1 ORDER BY created_at", + loginHistory: + "SELECT id,identifier,success,method,ip_address,user_agent,created_at FROM diary_login_history WHERE user_id=$1 ORDER BY id", + personalBackups: + "SELECT id,bytes,created_at FROM diary_user_backups WHERE user_id=$1 ORDER BY created_at", + registrationInvites: + "SELECT id,max_uses,use_count,expires_at,created_at FROM diary_registration_invites WHERE created_by=$1 ORDER BY id", + }; + const results = await Promise.all( + Object.entries(queries).map(async ([name, sql]) => [ + name, + (await pool.query(sql, [userId])).rows, + ]), + ); + const data = Object.fromEntries(results); + data.profile = data.profile[0]; + return { + format: "field-log-complete-account", + version: 1, + exportedAt: new Date().toISOString(), + ...data, + }; +} +app.get("/api/export/json", auth, async (req, res, next) => { + try { + const exported = await completeUserExport(req.user.id); + res.setHeader( + "Content-Disposition", + `attachment; filename=field-log-${new Date().toISOString().slice(0, 10)}.json`, + ); + res.json(exported); + } catch (e) { + next(e); + } +}); +app.get("/api/export/pdf", auth, async (req, res, next) => { + try { + const rows = await ownedEntries(req.user.id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename=field-log-${new Date().toISOString().slice(0, 10)}.pdf`, + ); + const doc = new PDFDocument({ margin: 54, bufferPages: true }); + doc.pipe(res); + doc + .fontSize(24) + .text("DAYMARK") + .fontSize(10) + .fillColor("#666") + .text(`Exported ${new Date().toLocaleString()}`) + .moveDown(2); + for (const r of rows) { + doc + .fillColor("#111") + .fontSize(16) + .text(r.title) + .fontSize(9) + .fillColor("#666") + .text( + `${r.entry_date instanceof Date ? r.entry_date.toISOString().slice(0, 10) : String(r.entry_date).slice(0, 10)} / ${(r.tags || []).join(", ")}`, + ) + .moveDown(0.5) + .fontSize(11) + .fillColor("#222") + .text((r.body || []).join("\n\n"), { lineGap: 3 }) + .moveDown(2); + if (doc.y > 700) doc.addPage(); + } + doc.end(); + } catch (e) { + next(e); + } +}); +app.get("/api/export/zip", auth, async (req, res, next) => { + try { + const rows = await ownedEntries(req.user.id), + exported = await completeUserExport(req.user.id); + res.setHeader("Content-Type", "application/zip"); + res.setHeader( + "Content-Disposition", + `attachment; filename=field-log-${new Date().toISOString().slice(0, 10)}.zip`, + ); + const zip = new ZipArchive({ zlib: { level: 9 } }); + zip.on("error", next); + zip.pipe(res); + zip.append(JSON.stringify(exported, null, 2), { name: "field-log.json" }); + for (const r of rows) { + const date = + r.entry_date instanceof Date + ? r.entry_date.toISOString().slice(0, 10) + : String(r.entry_date).slice(0, 10), + safe = `${date}-${r.id}-${r.title}` + .replace(/[^a-z0-9._-]+/gi, "-") + .slice(0, 100); + zip.append( + `# ${r.title}\n\n${date}\n\n${(r.body || []).join("\n\n")}\n\n${(r.tags || []).map((t) => `#${t}`).join(" ")}\n`, + { name: `entries/${safe}.md` }, + ); + for (const [i, m] of (r.media || []).entries()) { + if (!m.key) continue; + try { + zip.append(await mediaStore.getObject(mediaBucket, m.key), { + name: `media/${r.id}/${i}-${String(m.name || "attachment").replace(/[\\/]/g, "-")}`, + }); + } catch {} + } + } + await zip.finalize(); + } catch (e) { + next(e); + } +}); +async function appendUserBackup(zip, userId) { + const rows = await ownedEntries(userId), + exported = await completeUserExport(userId); + zip.append(JSON.stringify(exported, null, 2), { name: "field-log.json" }); + for (const row of rows) { + const date = row.entry_date instanceof Date + ? row.entry_date.toISOString().slice(0, 10) + : String(row.entry_date).slice(0, 10), + safe = `${date}-${row.id}-${row.title}` + .replace(/[^a-z0-9._-]+/gi, "-") + .slice(0, 100); + zip.append( + `# ${row.title}\n\n${date}\n\n${(row.body || []).join("\n\n")}\n\n${(row.tags || []).map((tag) => `#${tag}`).join(" ")}\n`, + { name: `entries/${safe}.md` }, + ); + for (const [index, media] of (row.media || []).entries()) { + if (!media.key) continue; + try { + zip.append(await mediaStore.getObject(mediaBucket, media.key), { + name: `media/${row.id}/${index}-${String(media.name || "attachment").replace(/[\\/]/g, "-")}`, + }); + } catch {} + } + } +} +app.get("/api/user-backups", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT id,bytes,created_at FROM diary_user_backups WHERE user_id=$1 ORDER BY created_at DESC", + [req.user.id], + ); + res.json(rows); + } catch (error) { next(error); } +}); +app.post("/api/user-backups", auth, async (req, res, next) => { + const id = crypto.randomUUID(), + fileName = `user-${req.user.id}-${id}.zip`, + target = path.join(backupPath, fileName); + try { + await mkdir(backupPath, { recursive: true }); + await new Promise(async (resolve, reject) => { + const output = createWriteStream(target), + zip = new ZipArchive({ zlib: { level: 9 } }); + output.on("close", resolve); + output.on("error", reject); + zip.on("error", reject); + zip.pipe(output); + try { + await appendUserBackup(zip, req.user.id); + await zip.finalize(); + } catch (error) { reject(error); } + }); + const information = await stat(target); + const { rows } = await pool.query( + "INSERT INTO diary_user_backups(id,user_id,file_name,bytes) VALUES($1,$2,$3,$4) RETURNING id,bytes,created_at", + [id, req.user.id, fileName, information.size], + ); + res.status(201).json(rows[0]); + } catch (error) { + await unlink(target).catch(() => {}); + next(error); + } +}); +app.get("/api/user-backups/:id/download", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "SELECT file_name FROM diary_user_backups WHERE id=$1 AND user_id=$2", + [req.params.id, req.user.id], + ); + if (!rows.length) return res.status(404).json({ error: "Backup not found" }); + res.download(path.join(backupPath, path.basename(rows[0].file_name)), `diary-backup-${req.params.id}.zip`); + } catch (error) { next(error); } +}); +app.delete("/api/user-backups/:id", auth, async (req, res, next) => { + try { + const { rows } = await pool.query( + "DELETE FROM diary_user_backups WHERE id=$1 AND user_id=$2 RETURNING file_name", + [req.params.id, req.user.id], + ); + if (!rows.length) return res.status(404).json({ error: "Backup not found" }); + await unlink(path.join(backupPath, path.basename(rows[0].file_name))).catch(() => {}); + res.status(204).end(); + } catch (error) { next(error); } +}); +app.get("/api/account/lifecycle", auth, async (req, res, next) => { + try { + const [account, journals] = await Promise.all([ + pool.query("SELECT deletion_requested_at,deletion_scheduled_for FROM diary_users WHERE id=$1", [req.user.id]), + pool.query("SELECT id,name FROM diary_journals WHERE owner_id=$1 ORDER BY is_default DESC,name", [req.user.id]), + ]); + res.json({ ...account.rows[0], journals: journals.rows }); + } catch (error) { next(error); } +}); +app.post("/api/account/deactivate", auth, async (req, res, next) => { + try { + const current = await pool.query("SELECT password_hash,is_admin FROM diary_users WHERE id=$1", [req.user.id]); + if (!(await verify(req.body.password || "", current.rows[0].password_hash))) + return res.status(401).json({ error: "Current password is incorrect" }); + if (current.rows[0].is_admin) { + const admins = await pool.query("SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL"); + if (admins.rows[0].count <= 1) return res.status(409).json({ error: "Promote another administrator before deactivating" }); + } + await pool.query("UPDATE diary_users SET disabled_at=NOW(),self_deactivated=true WHERE id=$1", [req.user.id]); + await pool.query("DELETE FROM diary_sessions WHERE user_id=$1", [req.user.id]); + res.clearCookie("diary_session", { path: "/" }); + res.json({ deactivated: true }); + } catch (error) { next(error); } +}); +app.post("/api/account/transfer", auth, async (req, res, next) => { + const client = await pool.connect(); + try { + const target = await client.query("SELECT id,username FROM diary_users WHERE username=lower($1) AND disabled_at IS NULL", [String(req.body.username || "")]); + if (!target.rows.length || +target.rows[0].id === +req.user.id) + return res.status(400).json({ error: "Choose another active account" }); + const journalId = req.body.journalId === "all" ? null : Number(req.body.journalId); + await client.query("BEGIN"); + const transferred = await client.query( + `UPDATE diary_journals SET owner_id=$1,is_default=false WHERE owner_id=$2 ${journalId ? "AND id=$3" : ""} RETURNING id`, + journalId ? [target.rows[0].id, req.user.id, journalId] : [target.rows[0].id, req.user.id], + ); + if (!transferred.rowCount) { await client.query("ROLLBACK"); return res.status(404).json({ error: "Owned journal not found" }); } + await client.query("DELETE FROM diary_journal_members WHERE user_id=$1 AND journal_id=ANY($2::bigint[])", [target.rows[0].id, transferred.rows.map((row) => row.id)]); + for (const journal of transferred.rows) + await client.query("INSERT INTO diary_journal_members(journal_id,user_id,role) VALUES($1,$2,'editor') ON CONFLICT(journal_id,user_id) DO UPDATE SET role='editor'", [journal.id, req.user.id]); + await client.query("COMMIT"); + res.json({ transferred: transferred.rowCount, owner: target.rows[0].username }); + } catch (error) { await client.query("ROLLBACK").catch(() => {}); next(error); } + finally { client.release(); } +}); +app.post("/api/account/deletion", auth, async (req, res, next) => { + try { + const current = await pool.query("SELECT password_hash,is_admin FROM diary_users WHERE id=$1", [req.user.id]); + if (!(await verify(req.body.password || "", current.rows[0].password_hash))) + return res.status(401).json({ error: "Current password is incorrect" }); + if (current.rows[0].is_admin) { + const admins = await pool.query("SELECT count(*)::int AS count FROM diary_users WHERE is_admin AND disabled_at IS NULL AND deletion_scheduled_for IS NULL"); + if (admins.rows[0].count <= 1) return res.status(409).json({ error: "Promote another administrator before scheduling deletion" }); + } + const { rows } = await pool.query("UPDATE diary_users SET deletion_requested_at=NOW(),deletion_scheduled_for=NOW()+INTERVAL '30 days' WHERE id=$1 RETURNING deletion_scheduled_for", [req.user.id]); + res.json(rows[0]); + } catch (error) { next(error); } +}); +app.delete("/api/account/deletion", auth, async (req, res, next) => { + try { + await pool.query("UPDATE diary_users SET deletion_requested_at=NULL,deletion_scheduled_for=NULL WHERE id=$1", [req.user.id]); + res.status(204).end(); + } catch (error) { next(error); } +}); +app.get("/api/security/login-history.csv", auth, async (req, res, next) => { + try { + const { rows } = await pool.query("SELECT success,method,ip_address,user_agent,created_at FROM diary_login_history WHERE user_id=$1 ORDER BY created_at DESC", [req.user.id]); + const quote = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", "attachment; filename=account-security-activity.csv"); + res.send(["success,method,ip_address,user_agent,created_at", ...rows.map((row) => [row.success, row.method, row.ip_address, row.user_agent, row.created_at.toISOString()].map(quote).join(","))].join("\n")); + } catch (error) { next(error); } +}); +app.delete("/api/account", auth, async (req, res, next) => { + const client = await pool.connect(); + try { + if (req.body.username !== req.user.username) + return res.status(400).json({ error: "Enter your username exactly" }); + const current = await client.query( + "SELECT password_hash,is_admin FROM diary_users WHERE id=$1", + [req.user.id], + ); + if (!(await verify(req.body.password || "", current.rows[0].password_hash))) + return res.status(401).json({ error: "Current password is incorrect" }); + await client.query("BEGIN"); + if (current.rows[0].is_admin) { + const admins = await client.query( + "SELECT id FROM diary_users WHERE is_admin AND disabled_at IS NULL FOR SHARE", + ); + if (admins.rowCount <= 1) { + await client.query("ROLLBACK"); + return res.status(409).json({ + error: "Promote another administrator before deleting this account", + }); + } + } + await client.query( + "DELETE FROM diary_entries WHERE user_id=$1 OR journal_id IN (SELECT id FROM diary_journals WHERE owner_id=$1)", + [req.user.id], + ); + await client.query("DELETE FROM diary_users WHERE id=$1", [req.user.id]); + await client.query("COMMIT"); + const keys = []; + for await (const object of mediaStore.listObjectsV2( + mediaBucket, + `${req.user.id}/`, + true, + )) + keys.push(object.name); + if (keys.length) await mediaStore.removeObjects(mediaBucket, keys); + res.clearCookie("diary_session", { path: "/" }); + res.json({ deleted: true }); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + next(error); + } finally { + client.release(); + } +}); +app.post( + "/api/import/json", + auth, + upload.single("file"), + async (req, res, next) => { + const client = await pool.connect(); + try { + if (!req.file) + return res + .status(400) + .json({ error: "JSON or Markdown file required" }); + const text = req.file.buffer.toString("utf8"); + let items; + if (/\.md$/i.test(req.file.originalname)) { + const lines = text.split(/\r?\n/), + title = ( + lines.find((l) => /^#\s+/.test(l)) || "# Imported note" + ).replace(/^#\s+/, ""), + date = + (text.match(/\b\d{4}-\d{2}-\d{2}\b/) || [])[0] || + new Date().toISOString().slice(0, 10), + tags = [...text.matchAll(/(?:^|\s)#([\w-]+)/g)].map((m) => m[1]); + items = [ + { + title, + date, + tags, + body: text + .replace(/^#\s+.*$/m, "") + .replace(date, "") + .trim() + .split(/\n\s*\n/), + }, + ]; + } else { + const data = JSON.parse(text); + items = Array.isArray(data) ? data : data.entries; + } + if (!Array.isArray(items) || items.length > 10000) + return res.status(400).json({ error: "Invalid Daymark import" }); + const defaultJournal = await writableJournal(req.user.id, null); + await client.query("BEGIN"); + let imported = 0; + for (const x of items) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(x.date || "")) continue; + await client.query( + `INSERT INTO diary_entries(user_id,entry_date,title,body,tags,media,link,visibility,mood,location_name,latitude,longitude,pinned,journal_id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + [ + req.user.id, + x.date, + x.title || "Untitled", + JSON.stringify(Array.isArray(x.body) ? x.body : []), + JSON.stringify(Array.isArray(x.tags) ? x.tags : []), + JSON.stringify( + Array.isArray(x.media) + ? x.media.filter((m) => String(m.url || "").startsWith("data:")) + : [], + ), + x.link || "", + "private", + x.mood || "", + x.location || "", + x.latitude || null, + x.longitude || null, + !!x.pinned, + defaultJournal?.id, + ], + ); + imported++; + } + await client.query("COMMIT"); + res.status(201).json({ imported }); + } catch (e) { + await client.query("ROLLBACK"); + if (e instanceof SyntaxError) + return res.status(400).json({ error: "Invalid JSON" }); + next(e); + } finally { + client.release(); + } + }, +); +app.use(express.static(path.join(root, "dist"), { maxAge: "1d" })); +app.get("*all", (_q, r) => r.sendFile(path.join(root, "dist", "index.html"))); +app.use((e, _q, r, _n) => { + console.error(e); + r.status(500).json({ error: "Internal server error" }); +}); +async function purgeScheduledAccounts() { + const { rows } = await pool.query("SELECT id FROM diary_users WHERE deletion_scheduled_for IS NOT NULL AND deletion_scheduled_for<=NOW()"); + for (const user of rows) { + const backups = await pool.query("SELECT file_name FROM diary_user_backups WHERE user_id=$1", [user.id]); + await pool.query("DELETE FROM diary_users WHERE id=$1", [user.id]); + const keys = []; + for await (const object of mediaStore.listObjectsV2(mediaBucket, `${user.id}/`, true)) keys.push(object.name); + if (keys.length) await mediaStore.removeObjects(mediaBucket, keys); + for (const backup of backups.rows) await unlink(path.join(backupPath, path.basename(backup.file_name))).catch(() => {}); + } +} +migrate() + .then(() => { + app.listen(port, "0.0.0.0", () => console.log(`Daymark listening on :${port}`)); + deliverNotificationEmails().catch((error) => console.error(error)); + setInterval(() => deliverNotificationEmails().catch((error) => console.error(error)), 60000).unref(); + purgeScheduledAccounts().catch((error) => console.error(error)); + setInterval(() => purgeScheduledAccounts().catch((error) => console.error(error)), 3600000).unref(); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..1805a55 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,5681 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import exifr from "exifr"; +import { createWorker } from "tesseract.js"; +import { CircleMarker, MapContainer, Popup, TileLayer } from "react-leaflet"; +import "leaflet/dist/leaflet.css"; +import { + startRegistration, + startAuthentication, + browserSupportsWebAuthn, +} from "@simplewebauthn/browser"; +import { + Search, + Plus, + X, + Paperclip, + ChevronLeft, + ChevronRight, + Menu, + MapPin, + Pin, + LogOut, + Pencil, + Trash2, + RotateCcw, + History, + Download, + Upload, + Bold, + Italic, + Heading, + List, + ListChecks, + Quote, + Code, + Link2, + Eye, + Maximize2, + Minimize2, + CalendarDays, + Images, + Rows3, + MapPinned, + SlidersHorizontal, + BarChart3, + BookOpen, + Settings, + ArrowUp, + ArrowDown, + MessageSquare, + Bell, + Activity, + Network, + UserRound, + FolderOpen, + ShieldCheck, + KeyRound, + Users, + LockKeyhole, + Camera, + Mic, + Square, + HardDrive, + Archive, + ThumbsUp, + Share2, + Copy, + Mail, + Send, +} from "lucide-react"; +import "./styles.css"; +const nativeFetch = window.fetch.bind(window); +window.fetch = (input, options = {}) => { + const method = String(options.method || "GET").toUpperCase(); + if (!["GET", "HEAD", "OPTIONS"].includes(method)) { + const csrf = document.cookie + .split(";") + .map((item) => item.trim().split("=")) + .find(([name]) => name === "field_log_csrf")?.[1]; + if (csrf) { + const headers = new Headers(options.headers || {}); + headers.set("X-CSRF-Token", csrf); + options = { ...options, headers }; + } + } + return nativeFetch(input, options); +}; +const bytesToBase64 = (bytes) => + btoa(String.fromCharCode(...new Uint8Array(bytes))); +const base64ToBytes = (value) => + Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); +async function vaultKey(passphrase, salt) { + const material = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(passphrase), + "PBKDF2", + false, + ["deriveKey"], + ); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: 310000, hash: "SHA-256" }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} +async function encryptEntryPayload(value, passphrase) { + const salt = crypto.getRandomValues(new Uint8Array(16)), + iv = crypto.getRandomValues(new Uint8Array(12)), + key = await vaultKey(passphrase, salt), + ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode(JSON.stringify(value)), + ); + return { + algorithm: "AES-256-GCM", + kdf: "PBKDF2-SHA256", + iterations: 310000, + salt: bytesToBase64(salt), + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(ciphertext), + }; +} +async function decryptEntryPayload(payload, passphrase) { + const key = await vaultKey(passphrase, base64ToBytes(payload.salt)), + plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: base64ToBytes(payload.iv) }, + key, + base64ToBytes(payload.ciphertext), + ); + return JSON.parse(new TextDecoder().decode(plaintext)); +} +const starter = [ + { + id: 1, + date: "2025-06-21", + title: "The longest light", + tags: ["garden", "life"], + body: [ + `The sun stayed in the kitchen long after dinner, gilding the jam jars and every speck of flour on the counter. I left the back door open and listened to the blackbirds trade their last few notes across the roofs.`, + `There was nowhere to be. That felt, for once, less like an absence and more like a gift. I watered the tomatoes, tied up the tallest one, and found the first small green fruit hiding beneath a leaf.`, + `A day doesn’t need to announce itself to be remembered. Sometimes it is enough that the light was kind, the coffee was strong, and everyone you love got home safely.`, + ], + media: [], + }, + { + id: 2, + date: "2025-06-18", + title: "Rain on the workshop roof", + tags: ["making", "weather"], + body: [ + `A proper northern rain arrived before lunch and drummed on the zinc roof until every other sound disappeared. The kind of rain that gives you permission to stay exactly where you are.`, + `I sharpened the old plane and finally fitted the drawer that has been waiting on the bench since April. It still catches slightly on the left, but I’ve decided that perfection is mostly a very elegant form of procrastination.`, + ], + media: [], + }, + { + id: 3, + date: "2025-06-12", + title: "Train south, window seat", + tags: ["travel", "observations"], + body: [ + `Fields flickered past in green and yellow strips. Every small station offered a life I could almost imagine: the red bicycle by the ticket machine, two cups left on a café table, a dog waiting patiently at a crossing.`, + `Across the aisle, a boy was explaining the entire solar system to his grandmother with the aid of a bread roll and three grapes. Saturn was eaten somewhere after Hanover.`, + ], + media: [], + }, + { + id: 4, + date: "2025-05-30", + title: "Things planted, things repaired", + tags: ["garden", "making"], + body: [ + `The basil has taken over the sill, which is exactly the sort of quiet ambition I admire. I moved half of it outside and gave the rest to Marta next door.`, + `Replaced the loose hinge on the garden gate, oiled the handle, and stood back with the disproportionate satisfaction that follows a five-minute repair postponed for six months.`, + ], + media: [], + }, +]; +const fd = (d, o) => + new Intl.DateTimeFormat("en-GB", o).format(new Date(d + "T12:00:00")); +const monthNames = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; +const writingPrompts = [ + "What small detail from today would otherwise be forgotten?", + "What surprised you, and why?", + "Describe one conversation exactly as you remember it.", + "What changed between the start and end of today?", + "Write about a sound, smell, or texture you noticed.", + "What are you carrying forward into tomorrow?", +]; +function App() { + const initialParameters = new URLSearchParams(location.search), + requestedYear = +initialParameters.get("year"), + requestedDate = /^\d{4}-\d{2}-\d{2}$/.test(initialParameters.get("date") || "") ? initialParameters.get("date") : "", + [entries, setEntries] = useState([]), + [siteSettings, setSiteSettings] = useState({ + site_name: "Daymark", + site_tagline: "A private record of days, places, people, and ideas.", + site_notice: "NOTES FROM HOME AND AWAY", + footer_text: "WORDS, IMAGES & OTHER EVIDENCE", + maintenance_active: false, + }), + [siteReady, setSiteReady] = useState(false), + [maintenanceBypass, setMaintenanceBypass] = useState(false), + [user, setUser] = useState(null), + [preferences, setPreferences] = useState(null), + [journals, setJournals] = useState([]), + [journalFilter, setJournalFilter] = useState("all"), + [shareEntry, setShareEntry] = useState(null), + [authReady, setAuthReady] = useState(false), + [q, setQ] = useState(""), + [tag, setTag] = useState(initialParameters.get("tag") || "all"), + [filters, setFilters] = useState({}), + [timelineDate, setTimelineDate] = useState(requestedDate), + [searchResults, setSearchResults] = useState(null), + [filtersOpen, setFiltersOpen] = useState(false), + [peek, setPeek] = useState(null), + [uploadProgress, setUploadProgress] = useState(0), + [compose, setCompose] = useState(false), + [editing, setEditing] = useState(null), + [deleting, setDeleting] = useState(null), + [history, setHistory] = useState(null), + [view, setView] = useState( + ["diary", "calendar", "timeline", "gallery", "map", "stats"].includes(new URLSearchParams(location.search).get("view")) ? new URLSearchParams(location.search).get("view") : "diary", + ), + [selectedYear, setSelectedYear] = useState( + Number.isInteger(requestedYear) && requestedYear > 1900 + ? requestedYear + : new Date().getFullYear(), + ), + [side, setSide] = useState(false); + useEffect(() => { + fetch("/api/site-settings") + .then((response) => (response.ok ? response.json() : Promise.reject())) + .then((settings) => { + setSiteSettings(settings); + document.title = settings.site_name; + }) + .catch(() => {}) + .finally(() => setSiteReady(true)); + }, []); + useEffect(() => { + fetch("/api/auth/me") + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((u) => { + setUser(u); + return Promise.all([ + fetch("/api/entries").then((response) => response.json()), + fetch("/api/journals").then((response) => response.json()), + fetch("/api/preferences").then((response) => response.json()), + ]); + }) + .then(([data, journalData, preferenceData]) => { + setEntries(data); + setJournals(journalData); + setPreferences(preferenceData); + if (!new URLSearchParams(location.search).has("view")) + setView(["diary", "calendar", "timeline", "gallery", "map", "stats"].includes(preferenceData.defaultView) ? preferenceData.defaultView : "diary"); + if (data[0] && !requestedYear) + setSelectedYear(+data[0].date.slice(0, 4)); + }) + .catch(() => {}) + .finally(() => setAuthReady(true)); + }, []); + useEffect(() => { + if (!preferences) return; + const root = document.documentElement; + root.dataset.theme = preferences.theme || "tokyo-night"; + root.dataset.density = preferences.density || "comfortable"; + root.classList.toggle("reduce-motion", !!preferences.reduceMotion); + root.classList.toggle("compact-entries", !!preferences.compactEntries); + root.style.zoom = String(1.1 * ((Number(preferences.fontScale) || 100) / 100)); + root.style.setProperty("--user-accent", preferences.accentColor || "#7aa2f7"); + const safeHeader = String(preferences.headerImage || "").replace(/["\\\n\r]/g, ""); + root.style.setProperty("--diary-header-image", safeHeader ? `url("${safeHeader}")` : "none"); + }, [preferences]); + useEffect(() => { + if (!user) return; + const parameters = new URLSearchParams(location.search); + if (parameters.has("new") || parameters.has("share")) setCompose(true); + const flushOutbox = async () => { + const key = `field-log:outbox:${user.id}`, + queued = JSON.parse(localStorage.getItem(key) || "[]"), + remaining = []; + for (const item of queued) { + try { + const response = await fetch(item.url, { + method: item.method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(item.draft), + }); + if (!response.ok) remaining.push(item); + } catch { + remaining.push(item); + } + } + localStorage.setItem(key, JSON.stringify(remaining)); + if (queued.length && !remaining.length) { + const refreshed = await fetch("/api/entries"); + if (refreshed.ok) setEntries(await refreshed.json()); + } + }; + flushOutbox(); + window.addEventListener("online", flushOutbox); + return () => window.removeEventListener("online", flushOutbox); + }, [user]); + useEffect(() => { + if (!user || !("Notification" in window)) return; + const timers = entries + .filter((item) => item.reminderAt) + .map((item) => { + const key = `field-log:reminded:${item.id}:${item.reminderAt}`, + delay = new Date(item.reminderAt).getTime() - Date.now(); + if (localStorage.getItem(key) || delay < 0 || delay > 2147483647) + return null; + return setTimeout(async () => { + localStorage.setItem(key, "1"); + const options = { + body: + item.encrypted && !item._decrypted + ? "Encrypted entry reminder" + : item.title, + icon: "/field-log-icon.svg", + tag: `field-log-${item.id}`, + }; + if (navigator.serviceWorker?.controller) + (await navigator.serviceWorker.ready).showNotification( + "DAYMARK REMINDER", + options, + ); + else new Notification("DAYMARK REMINDER", options); + }, delay); + }) + .filter(Boolean); + return () => timers.forEach(clearTimeout); + }, [entries, user]); + useEffect(() => { + if (!user || !preferences?.recurringPromptTime || !preferences?.recurringPromptDays?.length) return; + const check = () => { + const now = new Date(), day = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"][now.getDay()], time = now.toTimeString().slice(0, 5), key = `field-log:prompt:${user.id}:${now.toISOString().slice(0, 10)}`; + if (preferences.recurringPromptDays.includes(day) && time === preferences.recurringPromptTime && !localStorage.getItem(key)) { + localStorage.setItem(key, "1"); + const body = (preferences.customPrompts || [])[0] || writingPrompts[Math.floor(Math.random() * writingPrompts.length)]; + if ("Notification" in window && Notification.permission === "granted") new Notification("DIARY WRITING PROMPT", { body, icon: "/field-log-icon.svg" }); + } + }; + check(); + const timer = setInterval(check, 30000); + return () => clearInterval(timer); + }, [user, preferences]); + useEffect(() => { + if (!user || !preferences?.notificationBrowser || !("Notification" in window) || Notification.permission !== "granted") return; + const quiet = () => { + const start = preferences.quietHoursStart, end = preferences.quietHoursEnd; + if (!start || !end || start === end) return false; + const now = new Date().toTimeString().slice(0, 5); + return start < end ? now >= start && now < end : now >= start || now < end; + }; + const deliver = async () => { + if (quiet()) return; + const response = await fetch("/api/notifications?channel=browser&unread=1"); + if (!response.ok) return; + const items = await response.json(), key = `field-log:browser-notifications:${user.id}`, delivered = new Set(JSON.parse(localStorage.getItem(key) || "[]")); + for (const item of items.reverse()) { + if (delivered.has(item.id)) continue; + const action = item.type === "reply" ? "replied to your comment" : item.type === "comment" ? "commented on your entry" : item.type.startsWith("security_") ? item.type.replaceAll("_", " ") : item.type.startsWith("shared_entry") ? item.type.replaceAll("_", " ") : "mentioned you"; + new Notification("DAYMARK", { body: `${item.actor || "Security"} ${action}${item.entry_title ? `: ${item.entry_title}` : ""}`, icon: "/field-log-icon.svg", tag: `diary-notification-${item.id}` }); + delivered.add(item.id); + } + localStorage.setItem(key, JSON.stringify([...delivered].slice(-200))); + }; + deliver(); + const timer = setInterval(deliver, 60000); + return () => clearInterval(timer); + }, [user, preferences]); + useEffect(() => { + const match = location.pathname.match(/^\/invite\/([^/]+)$/); + if (!user || !match) return; + fetch(`/api/invitations/${encodeURIComponent(match[1])}/accept`, { + method: "POST", + }) + .then(async (response) => { + const data = await response.json(); + if (!response.ok) throw new Error(data.error); + window.history.replaceState(null, "", "/"); + alert(`Joined “${data.journalName}” as ${data.role}.`); + location.reload(); + }) + .catch((error) => { + window.history.replaceState(null, "", "/"); + alert(error.message || "Could not accept invitation"); + }); + }, [user]); + useEffect(() => { + if (!user) return; + const params = new URLSearchParams(); + if (q.trim()) params.set("q", q.trim()); + if (tag !== "all") params.set("tag", tag); + Object.entries(filters).forEach(([key, value]) => { + if (value) params.set(key, value); + }); + if (![...params].length) { + setSearchResults(null); + return; + } + const controller = new AbortController(), + timer = setTimeout(() => { + fetch(`/api/search?${params}`, { signal: controller.signal }) + .then((response) => + response.ok ? response.json() : Promise.reject(), + ) + .then(setSearchResults) + .catch( + (error) => error.name !== "AbortError" && setSearchResults([]), + ); + }, 250); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [user, q, tag, filters]); + const tags = [...new Set(entries.flatMap((e) => e.tags))], + matches = useMemo( + () => + (searchResults ?? entries) + .filter( + (item) => + journalFilter === "all" || item.journalId === +journalFilter, + ) + .sort( + (first, second) => + Number(!!second.pinned) - Number(!!first.pinned) || + second.date.localeCompare(first.date) || + String(second.time || "").localeCompare(String(first.time || "")) || + Number(second.id) - Number(first.id), + ), + [entries, searchResults, journalFilter], + ), + shown = useMemo( + () => matches.filter((e) => +e.date.slice(0, 4) === selectedYear), + [matches, selectedYear], + ); + const add = async (e, selectedFiles = [], captions = [], mediaMetadata = []) => { + e.preventDefault(); + const f = new FormData(e.currentTarget), + files = selectedFiles.filter((x) => x.size && x.size <= 50 * 1024 * 1024), + body = String(f.get("body")).trim(), + remainingMedia = editing + ? JSON.parse(String(f.get("existingMedia") || "[]")) + : []; + if (!body && !files.length && !remainingMedia.length) return; + let queuedDraft = null; + try { + let media = []; + if (files.length) { + const uploadBody = new FormData(); + files.forEach((file) => uploadBody.append("media", file)); + uploadBody.append("captions", JSON.stringify(captions)); + uploadBody.append("metadata", JSON.stringify(mediaMetadata)); + media = await new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open("POST", "/api/media"); + const csrf = document.cookie + .split(";") + .map((item) => item.trim().split("=")) + .find(([name]) => name === "field_log_csrf")?.[1]; + if (csrf) request.setRequestHeader("X-CSRF-Token", csrf); + request.upload.onprogress = (event) => + event.lengthComputable && + setUploadProgress(Math.round((event.loaded / event.total) * 100)); + request.onload = () => + request.status >= 200 && request.status < 300 + ? resolve(JSON.parse(request.responseText)) + : reject( + new Error( + JSON.parse(request.responseText || "{}").error || + "Upload failed", + ), + ); + request.onerror = reject; + request.send(uploadBody); + }); + } + let draft = { + journalId: f.get("journalId"), + date: f.get("date"), + time: f.get("time"), + title: f.get("title") || "Untitled", + tags: String(f.get("tags") || "note") + .split(",") + .map((x) => x.trim()), + body: body ? body.split(/\n\s*\n/) : [], + media: editing ? [...remainingMedia, ...media] : media, + link: f.get("link"), + visibility: f.get("visibility"), + mood: f.get("mood"), + location: f.get("location"), + latitude: f.get("latitude"), + longitude: f.get("longitude"), + pinned: f.get("pinned") === "on", + reminderAt: f.get("reminderAt") + ? new Date(String(f.get("reminderAt"))).toISOString() + : null, + weather: JSON.parse(String(f.get("weather") || "null")), + }; + if (preferences?.locationPrecision === "hidden") { + draft.location = ""; + draft.latitude = ""; + draft.longitude = ""; + } else if (preferences?.locationPrecision === "city") { + draft.latitude = ""; + draft.longitude = ""; + } else if (preferences?.locationPrecision === "approximate") { + draft.latitude = draft.latitude ? Number(draft.latitude).toFixed(2) : ""; + draft.longitude = draft.longitude ? Number(draft.longitude).toFixed(2) : ""; + } + const encryptionPassphrase = String(f.get("encryptionPassphrase") || ""); + if (f.get("encrypted") === "on") { + if (encryptionPassphrase.length < 12) + throw new Error( + "Use an encryption passphrase of at least 12 characters", + ); + draft = { + date: draft.date, + time: draft.time, + journalId: draft.journalId, + pinned: draft.pinned, + media: draft.media, + reminderAt: draft.reminderAt, + encrypted: true, + encryptedPayload: await encryptEntryPayload( + draft, + encryptionPassphrase, + ), + }; + } + queuedDraft = draft; + if ( + draft.reminderAt && + "Notification" in window && + Notification.permission === "default" + ) + await Notification.requestPermission(); + const response = await fetch( + editing ? `/api/entries/${editing.id}` : "/api/entries", + { + method: editing ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + }, + ); + if (!response.ok) throw new Error("Save failed"); + const saved = await response.json(); + setEntries( + editing + ? entries.map((x) => (x.id === saved.id ? saved : x)) + : [saved, ...entries], + ); + setCompose(false); + setEditing(null); + if (!editing && preferences?.rememberEntryOptions) + localStorage.setItem(`field-log:entry-defaults:${user.id}`, JSON.stringify({ + journalId: String(f.get("journalId") || ""), + visibility: String(f.get("visibility") || "private"), + tags: String(f.get("tags") || ""), + weather: draft.weather || null, + })); + localStorage.removeItem(String(f.get("draftKey"))); + if (new URLSearchParams(location.search).has("share")) + window.history.replaceState(null, "", "/"); + setSelectedYear(+saved.date.slice(0, 4)); + setUploadProgress(0); + } catch (error) { + setUploadProgress(0); + if (!navigator.onLine && queuedDraft && !files.length) { + const key = `field-log:outbox:${user.id}`, + queued = JSON.parse(localStorage.getItem(key) || "[]"); + queued.push({ + method: editing ? "PUT" : "POST", + url: editing ? `/api/entries/${editing.id}` : "/api/entries", + draft: queuedDraft, + queuedAt: new Date().toISOString(), + }); + localStorage.setItem(key, JSON.stringify(queued)); + localStorage.removeItem(String(f.get("draftKey"))); + setCompose(false); + setEditing(null); + return alert( + "Saved to the offline outbox. It will sync when the connection returns.", + ); + } + alert( + error.message || + "Could not save the entry or its attachments. Please try again.", + ); + } + }; + const accountMatch = location.pathname.match(/^\/(verify|reset)\/([^/]+)$/); + if (accountMatch) + return ; + if (!authReady || !siteReady) + return
CONNECTING TO DAYMARK_
; + if ( + siteSettings.maintenance_active && + !user?.is_admin && + !maintenanceBypass + ) + return ( + setMaintenanceBypass(true)} + /> + ); + const publicMatch = location.pathname.match(/^\/share\/([^/]+)$/); + if (publicMatch) return ; + const authorMatch = location.pathname.match(/^\/authors\/([^/]+)$/); + if (authorMatch) return ; + if (!user) + return ( + { + setUser(u); + Promise.all([ + fetch("/api/entries").then((response) => response.json()), + fetch("/api/journals").then((response) => response.json()), + fetch("/api/preferences").then((response) => response.json()), + ]).then(([data, journalData, preferenceData]) => { + setEntries(data); + setJournals(journalData); + setPreferences(preferenceData); + if (!new URLSearchParams(location.search).has("view")) + setView(["diary", "calendar", "timeline", "gallery", "map", "stats"].includes(preferenceData.defaultView) ? preferenceData.defaultView : "diary"); + if (data[0] && !requestedYear) + setSelectedYear(+data[0].date.slice(0, 4)); + }); + }} + /> + ); + const logout = () => + fetch("/api/auth/logout", { method: "POST" }).then(() => { + setUser(null); + location.assign("/"); + }); + const removeEntry = async (entry) => { + const r = await fetch(`/api/entries/${entry.id}`, { method: "DELETE" }); + if (r.ok) { + setEntries((current) => current.filter((x) => x.id !== entry.id)); + setDeleting(null); + } + }; + const openHistory = async (entry) => { + const r = await fetch(`/api/entries/${entry.id}/revisions`); + if (r.ok) setHistory({ entry, revisions: await r.json() }); + }; + const unlockEntry = async (protectedEntry) => { + const passphrase = prompt("Enter this entry's encryption passphrase"); + if (!passphrase) return; + try { + const plaintext = await decryptEntryPayload( + protectedEntry.encryptedPayload, + passphrase, + ); + setEntries((current) => + current.map((item) => + item.id === protectedEntry.id + ? { ...item, ...plaintext, _decrypted: true } + : item, + ), + ); + } catch { + alert("That passphrase could not decrypt this entry."); + } + }; + const entryMatch = location.pathname.match(/^\/entries\/(\d+)$/); + if (entryMatch) + return ; + if (location.pathname === "/settings") + return ( + + ); + if (location.pathname === "/admin") + return user.is_admin ? ( + + ) : ( + + ); + return ( +
+ {siteSettings.site_notice && ( +
+ {siteSettings.site_name.toUpperCase()} / {siteSettings.site_notice} +
+ )} + {!user.email_verified_at && ( +
+ EMAIL NOT VERIFIED · CHECK MAILPIT OR YOUR INBOX + +
+ )} + setCompose(true)} + entries={entries} + settings={siteSettings} + /> +
+ +
+
+ + + + + + { + (view === "timeline" && timelineDate ? shown.filter((entry) => entry.date === timelineDate) : ["map", "stats"].includes(view) ? matches : shown) + .length + }{" "} + ENTRIES + +
+ {filtersOpen && ( + setFilters({ ...filters, [key]: value })} + clear={() => setFilters({})} + /> + )} + {view === "diary" && + shown.slice(0, preferences?.entriesPerPage || shown.length).map((e) => ( + { + setEditing(entry); + setCompose(true); + }} + onDelete={setDeleting} + onHistory={openHistory} + onShare={setShareEntry} + onUnlock={unlockEntry} + /> + ))} + {view === "calendar" && shown.length > 0 && ( + { + setTimelineDate(date); + setView("timeline"); + setFiltersOpen(false); + window.history.replaceState(null, "", `/?view=timeline&date=${date}&year=${date.slice(0, 4)}#diary`); + }} /> + )} + {view === "timeline" && entry.date === timelineDate) : shown} date={timelineDate} preferences={preferences} clearDate={() => { + setTimelineDate(""); + window.history.replaceState(null, "", `/?view=timeline&year=${selectedYear}#diary`); + }} />} + {view === "gallery" && } + {view === "map" && } + {view === "stats" && } + {(["map", "stats"].includes(view) + ? !matches.length + : !shown.length) && ( +
NO MATCHING ENTRIES_
+ )} +
+
+
+ {siteSettings.site_name.toUpperCase()} + {siteSettings.footer_text} + BACK TO TOP ↑ +
+ {compose && ( + { + setCompose(false); + setEditing(null); + }} + submit={add} + uploadProgress={uploadProgress} + /> + )} + {history && ( + setHistory(null)} + restore={async (revision) => { + const r = await fetch( + `/api/entries/${history.entry.id}/revisions/${revision.id}/restore`, + { method: "POST" }, + ); + if (r.ok) { + const restored = await r.json(); + setEntries( + entries.map((e) => (e.id === restored.id ? restored : e)), + ); + setHistory(null); + } + }} + /> + )} + {peek && setPeek(null)} />} + {shareEntry && ( + setShareEntry(null)} /> + )} + {deleting && ( + setDeleting(null)} + confirm={() => removeEntry(deleting)} + /> + )} +
+ ); +} +function PageHeader({ user, logout, newEntry, entries, settings }) { + const dates = entries?.map((entry) => entry.date).filter(Boolean).sort() || [], + earliestYear = dates[0]?.slice(0, 4) || "—", + latestDate = dates.at(-1), + hasDiaryIdentity = !!settings && Array.isArray(entries); + return ( +
+ {hasDiaryIdentity ? + {settings.site_name.toUpperCase()}_ +

{settings.site_tagline}

+ {entries.length.toLocaleString()} ENTRIES·RECORDING SINCE {earliestYear}·{latestDate ? `LAST ENTRY ${fd(latestDate, { day: "2-digit", month: "short", year: "numeric" }).toUpperCase()}` : "NO ENTRIES YET"} +
: GROG.DIARY} + +
+ ); +} +function PublicAuthorPage({ username }) { + const [data, setData] = useState(null), [error, setError] = useState(""); + useEffect(() => { + fetch(`/api/authors/${encodeURIComponent(username)}`) + .then(async (response) => { + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Profile not found"); + setData(result); + }) + .catch((reason) => setError(reason.message)); + }, [username]); + if (error) return
{error.toUpperCase()}_
; + if (!data) return
LOADING AUTHOR_
; + return
+ {data.profile.avatarUrl && } +
PUBLIC AUTHOR / @{data.profile.username}

{data.profile.displayName}

{data.profile.pronouns && {data.profile.pronouns}}

{data.profile.bio}

{data.profile.personalWebsite && WEBSITE ↗}
+
{data.entries.map((item) => )}{!data.entries.length &&
NO PUBLIC ENTRIES_
}
; +} +function EntryPage({ user, logout, entryId, preferences }) { + const [item, setItem] = useState(null), + [error, setError] = useState(""); + useEffect(() => { + fetch(`/api/entries/${entryId}`) + .then(async (response) => { + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "Entry not found"); + setItem(result); + }) + .catch((reason) => setError(reason.message)); + }, [entryId]); + useEffect(() => { + if (item && location.hash === "#discussion") + requestAnimationFrame(() => + document.getElementById("discussion")?.scrollIntoView(), + ); + }, [item]); + const unlock = async (protectedEntry) => { + const passphrase = prompt("Enter this entry's encryption passphrase"); + if (!passphrase) return; + try { + const plaintext = await decryptEntryPayload( + protectedEntry.encryptedPayload, + passphrase, + ); + setItem({ ...protectedEntry, ...plaintext, _decrypted: true }); + } catch { + alert("That passphrase could not decrypt this entry."); + } + }; + return ( +
+ +
+ + ← BACK TO DIARY + + {error &&
{error.toUpperCase()}_
} + {!item && !error &&
LOADING ENTRY_
} + {item && ( + <> + + + + )} +
+
+ ); +} +function SettingsPage({ + user, + setUser, + entries, + setEntries, + journals, + setJournals, + preferences, + setPreferences, + logout, +}) { + const sections = [ + ["profile", "Profile", UserRound], + ["diary", "Diary & editor", Pencil], + ["organize", "Journals & tags", BookOpen], + ["writing", "Writing", ListChecks], + ["appearance", "Appearance", Eye], + ["notifications", "Notifications", Bell], + ["privacy", "Privacy & sharing", LockKeyhole], + ["security-data", "Security", ShieldCheck], + ["data", "Data & storage", HardDrive], + ["trash", "Trash", Trash2], + ], + requested = location.hash.slice(1), + legacySections = { + journals: "organize", + tags: "organize", + editor: "diary", + inbox: "notifications", + activity: "notifications", + links: "privacy", + comments: "privacy", + blocks: "privacy", + storage: "data", + backups: "data", + }, + initialSection = legacySections[requested] || requested, + [section, setSection] = useState( + sections.some(([name]) => name === initialSection) ? initialSection : "profile", + ), + [trash, setTrash] = useState([]); + useEffect(() => { + if (section !== "trash") return; + fetch("/api/trash") + .then((response) => (response.ok ? response.json() : [])) + .then(setTrash); + }, [section]); + const choose = (name) => { + setSection(name); + window.history.replaceState(null, "", `/settings#${name}`); + }; + const close = () => location.assign("/"); + return ( +
+ +
+
+ ACCOUNT / USER SETTINGS +

{user.display_name}

+

+ @{user.username} · {user.email} +

+
+
+ +
+ {section === "profile" && } + {section === "diary" &&
} + {section === "organize" &&
} + {section === "writing" && } + {section === "appearance" && } + {section === "notifications" &&
} + {section === "privacy" &&
} + {section === "security-data" && } + {section === "data" &&
} + {section === "trash" && { + const response = await fetch(`/api/entries/${item.id}/restore`, { method: "POST" }); + if (response.ok) { + const restored = await response.json(); + setEntries([restored, ...entries]); + setTrash((current) => current.filter((entry) => entry.id !== item.id)); + } + }} destroy={async (item) => { + if (!confirm(`Permanently delete “${item.title}”? This cannot be undone.`)) return; + const response = await fetch(`/api/entries/${item.id}/permanent`, { method: "DELETE" }); + if (response.ok) setTrash((current) => current.filter((entry) => entry.id !== item.id)); + }} />} +
+
+
+
+ ); +} +function ProfileSettingsPanel({ user, preferences, update, updatePreferences }) { + const [message, setMessage] = useState(""); + const save = async (event) => { + event.preventDefault(); + const values = Object.fromEntries(new FormData(event.currentTarget)), + response = await fetch("/api/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + const result = await response.json(); + if (!response.ok) return setMessage(result.error || "Could not update profile."); + let avatarUrl = values.avatarUrl; + if (values.avatarFile?.size) { + const uploadBody = new FormData(); + uploadBody.append("media", values.avatarFile); + uploadBody.append("captions", "[]"); + const uploadResponse = await fetch("/api/media", { method: "POST", body: uploadBody }); + if (!uploadResponse.ok) return setMessage((await uploadResponse.json()).error || "Could not upload profile image."); + avatarUrl = (await uploadResponse.json())[0]?.url || avatarUrl; + } + const preferenceResponse = await fetch("/api/preferences", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...preferences, avatarUrl, pronouns: values.pronouns, personalWebsite: values.personalWebsite, homeLocation: values.homeLocation, language: values.language, timezone: values.timezone }), + }); + update(result); + if (preferenceResponse.ok) updatePreferences(await preferenceResponse.json()); + setMessage("Profile updated."); + setTimeout(() => setMessage(""), 3000); + }; + return
+
{preferences?.avatarUrl && Profile}PROFILE

Manage the identity shown beside your entries and comments.

+
+ + + + + + + + +