diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6c9be79..f260605 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,53 +1,88 @@ -# Use the official Dev Container base image for Debian Bookworm +# Use the official Dev Container base image for Debian Trixie # We use this instead of plain Debian to get a better development experience out of the box # as it includes common tools and configurations for development. -FROM mcr.microsoft.com/devcontainers/base:bookworm AS base +FROM mcr.microsoft.com/devcontainers/base:trixie AS base LABEL org.opencontainers.image.description="Development Container for Swindon Makerspace Access System" # libparse-debianchangelog-perl \ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends \ - perl \ - cpanminus \ - build-essential \ - libalgorithm-diff-perl \ - libalgorithm-diff-xs-perl \ - libalgorithm-merge-perl \ - libcgi-fast-perl \ - libcgi-pm-perl \ - libclass-accessor-perl \ - libclass-isa-perl \ - libencode-locale-perl \ - libfcgi-perl \ - libfile-fcntllock-perl \ - libhtml-parser-perl \ - libhtml-tagset-perl \ - libhttp-date-perl \ - libhttp-message-perl \ - libio-html-perl \ - libio-string-perl \ - liblocale-gettext-perl \ - liblwp-mediatypes-perl \ - libsub-name-perl \ - libswitch-perl \ - libtext-charwidth-perl \ - libtext-iconv-perl \ - libtext-wrapi18n-perl \ - libtimedate-perl \ - liburi-perl \ - libscalar-list-utils-perl \ - && cpanm -S Carton \ -# for Perl::LanguageServer + perl \ + cpanminus \ + build-essential \ + # Database drivers + libdbd-sqlite3-perl \ + libdbd-pg-perl \ + libpq-dev \ + # Libraries for CPAN XS modules + libexpat1-dev \ + libxml2-dev \ + zlib1g-dev \ + # Core Perl modules from apt + libalgorithm-diff-perl \ + libalgorithm-diff-xs-perl \ + libalgorithm-merge-perl \ + libcgi-fast-perl \ + libcgi-pm-perl \ + libclass-accessor-perl \ + libclass-isa-perl \ + libencode-locale-perl \ + libfcgi-perl \ + libfile-fcntllock-perl \ + libhtml-parser-perl \ + libhtml-tagset-perl \ + libhttp-date-perl \ + libhttp-message-perl \ + libio-html-perl \ + libio-string-perl \ + liblocale-gettext-perl \ + liblwp-mediatypes-perl \ + libsub-name-perl \ + libswitch-perl \ + libtext-charwidth-perl \ + libtext-iconv-perl \ + libtext-wrapi18n-perl \ + libtimedate-perl \ + liburi-perl \ + libscalar-list-utils-perl \ + libmoose-perl \ + libjson-perl \ + libdata-dump-perl \ + libtry-tiny-perl \ + libdatetime-perl \ + libpath-class-perl \ + libplack-perl \ + libxml-parser-perl \ + libcrypt-des-perl \ + libssl-dev \ + libio-socket-ssl-perl \ + libnet-ssleay-perl \ + ca-certificates \ + && cpanm -n Carton \ + # for Perl::LanguageServer && apt-get -y install --no-install-recommends \ - libanyevent-perl \ - libclass-refresh-perl \ - libdata-dump-perl \ - libio-aio-perl \ - libjson-perl \ - libmoose-perl \ - libpadwalker-perl \ - libscalar-list-utils-perl \ - libcoro-perl \ + libanyevent-perl \ + libclass-refresh-perl \ + libio-aio-perl \ + libpadwalker-perl \ + libcoro-perl \ && cpanm Perl::LanguageServer \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* /root/.cpanm + +# ============================================================================= +# DEPS STAGE - Install CPAN dependencies via Carton +# ============================================================================= +FROM base AS deps + +WORKDIR /workspace + +# Copy dependency files first (for better layer caching) +COPY cpanfile cpanfile.snapshot ./ + +# Create vendor directory structure (for cached install) +COPY vendor/ vendor/ + +# Install dependencies using cached mode +RUN carton install --cached \ + && rm -rf /root/.cpanm diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b35f24 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Git +.git/ +.gitignore +.github/ + +# Development files +.devcontainer/ +.vscode/ +.idea/ +*.code-workspace + +# Database files (should not be in image) +db/ +*.db +*.db.bak +rapidapp_coreschema.db + +# Documentation +docs/ +*.md +NOTES.txt +Changes + +# Config backups and local configs (mount at runtime) +config.bkp/ +accesssystem_api.conf +accesssystem_api_local.conf +accesssystem_dev.conf +accesssystem.conf +keys.conf + +# Keep example configs +!*.conf.example + +# Build artifacts +Makefile +META.yml +MYMETA.* +pm_to_blib +blib/ +inc/ + +# Editor/OS files +*~ +*.bak +.DS_Store +*.swp + +# OFX bank files +ofx/ +*.ofx + +# Test output +test_db.db diff --git a/.github/workflows/build-dev-image.yaml b/.github/workflows/build-dev-image.yaml index 44b4cac..bf4526b 100644 --- a/.github/workflows/build-dev-image.yaml +++ b/.github/workflows/build-dev-image.yaml @@ -70,7 +70,7 @@ jobs: with: context: . file: .devcontainer/Dockerfile - target: base + target: deps push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..9f58a18 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,99 @@ +name: CI - Test and Build + +on: + push: + branches: + - master + tags: + - 'v*' + pull_request: + branches: + - master + workflow_dispatch: + +env: + CONTAINER_REGISTRY: ghcr.io + IMAGE_NAME: access-system + +jobs: + test: + name: ๐Ÿงช Run Tests + runs-on: ubuntu-latest + + steps: + - name: ๐Ÿ“ฆ Checkout code + uses: actions/checkout@v6 + + - name: ๐Ÿ› ๏ธ Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: ๐Ÿ—๏ธ Build test image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: test + load: true + tags: accesssystem-test:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: ๐Ÿงช Run tests + run: | + docker run --rm accesssystem-test:latest + + build-production: + name: ๐Ÿš€ Build Production Image + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')) + permissions: + contents: read + packages: write + + steps: + - name: ๐Ÿ“ฆ Checkout code + uses: actions/checkout@v6 + + - name: ๐Ÿท๏ธ Generate Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.CONTAINER_REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch,priority=610 + type=semver,pattern={{raw}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,format=long + type=raw,value=latest,enable={{is_default_branch}} + annotations: | + runnumber=${{ github.run_id }} + sha=${{ github.sha }} + ref=${{ github.ref }} + org.opencontainers.image.description="Production Container for Swindon Makerspace Access System" + + - name: ๐Ÿ” Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.CONTAINER_REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: ๐Ÿ› ๏ธ Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: ๐Ÿš€ Build and push production image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: production + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bc2c849 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,135 @@ +# Multi-stage Dockerfile for AccessSystem +# Stages: base -> deps -> test -> production + +# ============================================================================= +# BASE STAGE - System dependencies and Perl packages from apt +# ============================================================================= +FROM debian:trixie-slim AS base + +LABEL org.opencontainers.image.description="AccessSystem - Swindon Makerspace Access Control" + +# Install system Perl and essential apt packages +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends \ + perl \ + cpanminus \ + build-essential \ + # Database drivers + libdbd-sqlite3-perl \ + libdbd-pg-perl \ + libpq-dev \ + # Libraries for CPAN XS modules + libexpat1-dev \ + libxml2-dev \ + zlib1g-dev \ + # Core Perl modules from apt (faster than CPAN) + libalgorithm-diff-perl \ + libalgorithm-diff-xs-perl \ + libalgorithm-merge-perl \ + libcgi-fast-perl \ + libcgi-pm-perl \ + libclass-accessor-perl \ + libencode-locale-perl \ + libfcgi-perl \ + libfile-fcntllock-perl \ + libhtml-parser-perl \ + libhtml-tagset-perl \ + libhttp-date-perl \ + libhttp-message-perl \ + libio-html-perl \ + libio-string-perl \ + liblocale-gettext-perl \ + liblwp-mediatypes-perl \ + libsub-name-perl \ + libtext-charwidth-perl \ + libtext-iconv-perl \ + libtext-wrapi18n-perl \ + libtimedate-perl \ + liburi-perl \ + libscalar-list-utils-perl \ + libmoose-perl \ + libjson-perl \ + libdata-dump-perl \ + libtry-tiny-perl \ + libdatetime-perl \ + libpath-class-perl \ + libplack-perl \ + # XML::Parser from apt (avoids build issues) + libxml-parser-perl \ + # Crypt::DES from apt (fails to build from CPAN, needed by RapidApp) + libcrypt-des-perl \ + # SSL/TLS support + libssl-dev \ + libio-socket-ssl-perl \ + libnet-ssleay-perl \ + ca-certificates \ + # For Carton + && cpanm -n Carton \ + && rm -rf /var/lib/apt/lists/* /root/.cpanm + +WORKDIR /app + +# ============================================================================= +# DEPS STAGE - Install CPAN dependencies via Carton +# ============================================================================= +FROM base AS deps + +# Copy dependency files first (for better layer caching) +COPY cpanfile cpanfile.snapshot ./ + +# Create vendor directory structure (for cached install) +COPY vendor/ vendor/ + +# Install dependencies using cached mode as per README +RUN carton install --cached \ + && rm -rf /root/.cpanm + +# ============================================================================= +# TEST STAGE - For running tests +# ============================================================================= +FROM deps AS test + +# Copy application code +COPY lib/ lib/ +COPY t/ t/ +COPY root/ root/ +COPY script/ script/ +COPY sql/ sql/ + +# Copy test and example configs - test config used as local override +COPY accesssystem_api.conf.example accesssystem_api.conf +COPY accesssystem_api_test.conf accesssystem_api_local.conf + +# Copy psgi files +COPY app.psgi accesssystem.psgi ./ + +# Set environment for tests +ENV CATALYST_HOME=/app +ENV PERL5LIB=/app/local/lib/perl5:/app/lib + +# Default command runs tests +CMD ["carton", "exec", "prove", "-I", "lib", "-I", "t/lib", "-r", "t/"] + +# ============================================================================= +# PRODUCTION STAGE - Slim production image +# ============================================================================= +FROM deps AS production + +# Copy only what's needed for production +COPY lib/ lib/ +COPY root/ root/ +COPY script/ script/ +COPY app.psgi accesssystem.psgi ./ + +# Config files should be mounted at runtime +# COPY accesssystem_api.conf accesssystem_api_local.conf ./ + +# Set environment +ENV CATALYST_HOME=/app +ENV PERL5LIB=/app/local/lib/perl5:/app/lib + +# Expose default Catalyst port +EXPOSE 3000 + +# Default command runs the API server +CMD ["carton", "exec", "perl", "script/accesssystem_api_server.pl", "--port", "3000", "--host", "0.0.0.0"] diff --git a/README.md b/README.md index 45c7b9e..62f195b 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,76 @@ The GET `confirm_email` endpoint confirms+stores the telegram chatid/username in The GET `induction_acceptance` endpoint, given a `tool` id and a `person` id, sets "pending acceptance" to false, for that combination of tool and person, in the `allowed` table. +API Documentation / OpenAPI +--------------------------- + +The system now supports automatic OpenAPI 3.1 specification generation. + +### OpenAPI Endpoints + +* **Spec**: `GET /openapi` - Returns the JSON specification. +* **UI**: `GET /openapi/ui` - Interactive Swagger UI documentation. + +### Adding New Routes + +To document a new endpoint, add POD documentation above your controller subroutine. The system automatically parses `Tags`, `Methods`, and parameter lists. + +**Format:** + +```perl +=head2 endpoint_name + +Description of what this endpoint does. + +Tags: Category Name +Methods: GET, POST + +=over + +=item param_name (required, integer) - Description of parameter + +=item optional_param - Description + +=back + +=cut + +sub endpoint_name : Path(...) { ... } +``` + +**Supported Modifiers:** `required`, `uuid`, `email`, `integer`, `boolean`, `number`. + +### Automatic Detection + +If `Methods:` is omitted, the system analyzes the source code to detect if `POST` is used (e.g. usage of `body_params` or database writes). Otherwise it defaults to `GET`. + +### CLI Tool + +You can inspect the generated spec from the command line: + + carton exec perl script/generate_openapi.pl > openapi.yaml + +### Testing + +Run the automated test suite: + + carton exec prove -lv t/ + +Run OpenAPI validation tests specifically: + + carton exec prove -lv t/openapi_validation.t + +**In Docker:** + + # Build test image + docker build --target test -t accesssystem-test . + + # Run all tests + docker run --rm accesssystem-test + + # Run OpenAPI validation only + docker run --rm accesssystem-test carton exec prove -lv /app/t/openapi_validation.t + Security & DPA -------------- diff --git a/accesssystem_api_test.conf b/accesssystem_api_test.conf new file mode 100644 index 0000000..45e060c --- /dev/null +++ b/accesssystem_api_test.conf @@ -0,0 +1,53 @@ +# Test configuration for AccessSystem API +# Used when running tests in CI or locally + + + + dsn dbi:SQLite:test_db.db + + + +# Dummy reCAPTCHA keys (will pass validation in non-production mode) + + site_key 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI + secret_key 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe + + +# Disable debug in test mode + + ignore_extensions [] + + +# Test email settings - don't actually send + + stash_key email + + content_type text/plain + charset utf-8 + + + +# Dummy OneAll settings + + subdomain test + domain test.api.oneall.com + public_key test-public-key + private_key test-private-key + + +# Cookie settings + + name access_system_test + mac_secret test-cookie-secret-for-testing-only + + + + namespace accesssystem + + +# Dummy Sendinblue/Brevo settings + + api-key dummy-test-api-key + + +base_url http://localhost:3000/accesssystem/ diff --git a/lib/AccessSystem/API/Controller/Root.pm b/lib/AccessSystem/API/Controller/Root.pm index 3814df8..445172d 100644 --- a/lib/AccessSystem/API/Controller/Root.pm +++ b/lib/AccessSystem/API/Controller/Root.pm @@ -10,6 +10,7 @@ use LWP::UserAgent; use MIME::Base64; use JSON; use Data::GUID; +use AccessSystem::API::OpenAPI; BEGIN { extends 'Catalyst::Controller' } @@ -70,6 +71,22 @@ sub default :Path { # insert into tools (id, name, assigned_ip) values ('D1CAE50C-0C2C-11E7-84F0-84242E34E104', 'oneall_login_callback', '192.168.1.70'); +=head2 oneall_login_callback + +Callback for OneAll social login. + +Tags: Authentication + +Methods: POST + +=over + +=item connection_token (required) - OneAll connection token + +=back + +=cut + sub oneall_login_callback : Path('/oneall_login_callback') { my ($self, $c) = @_; @@ -124,12 +141,32 @@ sub oneall_login_callback : Path('/oneall_login_callback') { } } +=head2 login + +Login page. + +Tags: Authentication + +Methods: GET + +=cut + sub login : Path('/login') { my ($self, $c) = @_; $c->stash(template => 'login.tt'); } +=head2 logout + +Logout action. + +Tags: Authentication + +Methods: GET + +=cut + sub logout : Path('/logout') { my ($self, $c) = @_; @@ -172,6 +209,16 @@ sub logged_in: Chained('base') :PathPart(''): CaptureArgs(0) { $c->stash->{person} = $person; } +=head2 profile + +Member profile page. + +Tags: Member Registration + +Methods: GET + +=cut + sub profile : Chained('logged_in') :PathPart('profile'): Args(0) { my ($self, $c) = @_; @@ -187,6 +234,30 @@ sub profile : Chained('logged_in') :PathPart('profile'): Args(0) { $c->stash->{template} = 'profile.tt'; } +=head2 editme + +Member profile edit form. + +Tags: Member Registration + +Methods: GET, POST + +=over + +=item name - Full name + +=item email - Email address + +=item address - Physical address + +=item postcode - Postcode + +=item opt_in - Marketing opt-in + +=back + +=cut + sub editme : Chained('logged_in') :PathPart('editme'): Args(0) { my ($self, $c) = @_; @@ -206,6 +277,16 @@ sub editme : Chained('logged_in') :PathPart('editme'): Args(0) { } } +=head2 download_data + +Download member data as JSON. + +Tags: Member Registration + +Methods: GET + +=cut + sub download_data: Chained('logged_in') :PathPart('download'): Args(0) { my ($self, $c) = @_; @@ -223,6 +304,22 @@ sub download_data: Chained('logged_in') :PathPart('download'): Args(0) { } +=head2 delete_me + +Delete member account. + +Tags: Member Registration + +Methods: GET, POST + +=over + +=item reallyreally (required) - Confirmation string "yupyup" to delete account + +=back + +=cut + sub delete_me :Chained('logged_in'): PathPart('deleteme'): Args(0) { my ($self, $c) = @_; @@ -235,6 +332,23 @@ sub delete_me :Chained('logged_in'): PathPart('deleteme'): Args(0) { } } +=head2 delete_token + +Delete an access token. +These are GET reqs (cos lazy, and its a link) + +Tags: Member Registration + +Methods: GET + +=over + +=item token (required) - Token ID to delete + +=back + +=cut + sub delete_token :Chained('logged_in'): PathPart('delete_token'): Args(0) { my ($self, $c) = @_; my $token = $c->req->params->{token}; @@ -250,6 +364,23 @@ sub delete_token :Chained('logged_in'): PathPart('delete_token'): Args(0) { return $c->response->redirect($c->uri_for('profile')); } +=head2 delete_vehicle + +Delete a vehicle. +These are GET reqs (cos lazy, and its a link) + +Tags: Member Registration + +Methods: GET + +=over + +=item vehicle (required) - Vehicle registration to delete + +=back + +=cut + sub delete_vehicle :Chained('logged_in'): PathPart('delete_vehicle'): Args(0) { my ($self, $c) = @_; my $vehicle = $c->req->params->{vehicle}; @@ -262,6 +393,22 @@ sub delete_vehicle :Chained('logged_in'): PathPart('delete_vehicle'): Args(0) { return $c->response->redirect($c->uri_for('profile')); } +=head2 who + +Identify a member by their token. + +Tags: Access Control + +Methods: GET + +=over + +=item token (required) - Access token ID + +=back + +=cut + sub who : Chained('base') : PathPart('who') : Args(0) { my ($self, $c) = @_; @@ -278,6 +425,24 @@ sub who : Chained('base') : PathPart('who') : Args(0) { ## eg "the Main Door", check whether they both exist as ids, and ## whether a person owning said token is allowed to access said thing. +=head2 verify + +Verify access for a token at a thing/controller. + +Tags: Access Control + +Methods: GET + +=over + +=item token (required) - Access token ID + +=item thing (required, uuid) - Controller GUID + +=back + +=cut + sub verify: Chained('base') :PathPart('verify') :Args(0) { my ($self, $c) = @_; @@ -343,15 +508,17 @@ sub verify: Chained('base') :PathPart('verify') :Args(0) { $c->res->content_length(length($c->res->body)); } -=head2 msglog +=head2 msg_log -Query Params: +Logs a message from a thing controller. + +Tags: Access Control =over -=item thing = GUID of the thing controller doing the checking +=item thing (required, uuid) - GUID of the thing controller doing the checking -=item msg = text of the message to save +=item msg (required) - text of the message to save =back @@ -382,21 +549,21 @@ sub msg_log: Chained('base'): PathPart('msglog'): Args() { =head2 tool_access -Update UsageLog/state for a tool +Update UsageLog/state for a tool. -Query Params: +Tags: Access Control =over -=item token = user's access token id +=item token (required) - User's access token ID -=item thing = GUID of the thing controller doing the checking +=item thing (required, uuid) - GUID of the thing controller -=item msg = description of whats happening +=item msg - Description of activity -=item state = 1 (on), 0 (off) +=item state - 1 (on) or 0 (off) -=item active_time = number of seconds tool has been active +=item active_time - Number of seconds tool has been active =back @@ -447,6 +614,23 @@ sub tool_access: Chained('base'): PathPart('tool_access'): Args() { } ## Thing X (from correct IP Y) says person T inducts person S to use it: +=head2 induct + +Record induction between two tokens/members. + +Tags: Access Control + +=over + +=item token_t (required) - Trainer token + +=item token_s (required) - Student token + +=item thing (required, uuid) - Tool GUID + +=back + +=cut sub induct: Chained('base'): PathPart('induct'): Args() { my ($self, $c) = @_; @@ -505,6 +689,26 @@ sub induct: Chained('base'): PathPart('induct'): Args() { $c->forward('View::JSON'); } +=head2 assign + +Assign a token to a member (Admin). + +Tags: Access Control + +=over + +=item admin_token (required) - Admin's token + +=item person_id (required) - Member ID to assign to + +=item token_id (required) - New Token ID + +=item thing (required) - Tag Assigner GUID + +=back + +=cut + sub assign: Chained('base'): PathPart('assign'): Args(0) { my ($self, $c) = @_; @@ -568,6 +772,22 @@ sub assign: Chained('base'): PathPart('assign'): Args(0) { $c->forward('View::JSON'); } +=head2 park + +Parks a vehicle for a member identified by their token. + +Tags: Access Control + +=over + +=item token (required) - Access token ID of the member + +=item thing (required, uuid) - GUID of the parking controller + +=back + +=cut + sub park: Chained('base'): PathPart('park'): Args(0) { my ($self, $c) = @_; @@ -645,6 +865,30 @@ sub park: Chained('base'): PathPart('park'): Args(0) { $c->res->content_length(length($c->res->body)); } +=head2 record_transaction + +Record a financial transaction. + +Tags: Transactions + +Methods: POST + +=over + +=item amount (required) - Amount in pence (negative for debit) + +=item reason (required) - Reason for transaction + +=item token - Member token ID (if using token) + +=item thing - Controller GUID (if using token) + +=item hash - Member Hash (if using hash) + +=back + +=cut + sub record_transaction: Chained('base'): PathPart('transaction'): Args(0) { my ($self, $c) = @_; @@ -725,7 +969,19 @@ sub record_transaction: Chained('base'): PathPart('transaction'): Args(0) { =head2 get_transactions -Get most N recent transactions +Get most N recent transactions. + +Tags: Transactions + +Methods: GET + +=over + +=item count (required) - Number of transactions to retrieve + +=item userhash (required) - User hash + +=back =cut @@ -758,9 +1014,21 @@ sub get_transactions: Chained('base'): PathPart('get_transactions'): Args(2) { =head2 user_guid_request +Request user GUID email for app login. + Given a user id, send the member with that id an email, containing their guid. This is for putting into the phone app. +Tags: Access Control + +Methods: GET + +=over + +=item userid (required) - Member Reference (e.g. SM00123) + +=back + =cut sub user_guid_request: Chained('base'): PathPart('user_guid_request'): Args(0) { @@ -809,6 +1077,26 @@ sub user_guid_request: Chained('base'): PathPart('user_guid_request'): Args(0) { $c->forward($c->view('JSON')); } +=head2 confirm_telegram + +Initiate Telegram confirmation. + +Tags: Telegram + +Methods: GET + +=over + +=item email (required) - Member email address + +=item chatid (required) - Telegram chat ID + +=item username - Telegram username + +=back + +=cut + sub confirm_telegram: Chained('base'): PathPart('confirm_telegram'): Args(0) { my ($self, $c) = @_; @@ -856,6 +1144,22 @@ sub confirm_telegram: Chained('base'): PathPart('confirm_telegram'): Args(0) { $c->forward($c->view('JSON')); } +=head2 confirm_email + +Confirm email/telegram token. + +Tags: Telegram + +Methods: GET + +=over + +=item token (required) - Confirmation token + +=back + +=cut + sub confirm_email: Chained('base'): PathPart('confirm_email'): Args(0) { my ($self, $c) = @_; @@ -870,6 +1174,22 @@ sub confirm_email: Chained('base'): PathPart('confirm_email'): Args(0) { return $c->res->redirect($c->uri_for('post_confirm', { type => 'telegram' })); } +=head2 post_confirm + +Confirmation success page. + +Tags: Telegram + +Methods: GET + +=over + +=item type (required) - Confirmation type (e.g. 'telegram') + +=back + +=cut + sub post_confirm: Chained('base'): PathPart('post_confirm'): Arg(0) { my ($self, $c) = @_; @@ -878,6 +1198,24 @@ sub post_confirm: Chained('base'): PathPart('post_confirm'): Arg(0) { $c->stash->{template} = 'post_confirm.tt'; } +=head2 send_induction_acceptance + +Send induction acceptance email. + +Tags: Admin + +Methods: GET + +=over + +=item tool (required) - Tool ID + +=item person (required) - Person ID + +=back + +=cut + sub send_induction_acceptance: Chained('base'): PathPart('send_induction_acceptance'): Args(0) { my ($self, $c) = @_; @@ -913,6 +1251,22 @@ sub send_induction_acceptance: Chained('base'): PathPart('send_induction_accepta my $token = $c->req->params->{token}; } +=head2 confirm_induction + +Confirm induction acceptance. + +Tags: Admin + +Methods: GET + +=over + +=item token (required) - Induction confirmation token + +=back + +=cut + sub confirm_induction: Chained('base'): PathPart('confirm_induction'): Args(0) { my ($self, $c) = @_; @@ -928,6 +1282,8 @@ sub confirm_induction: Chained('base'): PathPart('confirm_induction'): Args(0) { =head2 get_dues +Calculate dues based on input parameters. + Returns amount of dues, in pence, which would be payable using current input values of: date of birth (dob), concession rate (concessionary_rate_override), and tier (tier) chosen. @@ -935,6 +1291,21 @@ input values of: date of birth (dob), concession rate Used by the L page to live-update dues values when prospective members change rates/concession choices. + +Tags: Member Registration + +Methods: GET + +=over + +=item dob - Date of Birth + +=item concessionary_rate_override - Concession rate + +=item tier - Tier ID + +=back + =cut sub get_dues: Chained('base'): PathPart('get_dues'): Args(0) { @@ -956,6 +1327,30 @@ sub get_dues: Chained('base'): PathPart('get_dues'): Args(0) { $c->response->body($dummy_dues / 100); } +=head2 register + +New member registration form. + +Tags: Member Registration + +Methods: GET, POST + +=over + +=item name (required) - Full Name + +=item email (required) - Email Address + +=item address (required) - Physical Address + +=item postcode (required) - Postcode + +=item dob - Date of Birth + +=back + +=cut + sub register: Chained('base'): PathPart('register'): Args(0) { my ($self, $c) = @_; @@ -990,6 +1385,24 @@ sub register: Chained('base'): PathPart('register'): Args(0) { } } +=head2 add_child + +Add child form for registration. + +Tags: Member Registration + +Methods: GET, POST + +=over + +=item name (required) - Child's Name + +=item dob (required) - Child's DOB + +=back + +=cut + sub add_child: Chained('base') :PathPart('add_child') :Args(0) { my ($self, $c) = @_; @@ -1065,6 +1478,22 @@ sub finish_new_member: Private { } +=head2 resend_email + +Resend membership email. + +Tags: Admin + +Methods: GET + +=over + +=item id (required) - Member ID + +=back + +=cut + sub resend_email: Chained('base'): PathPart('resendemail'): Args(1) { my ($self, $c, $id) = @_; my $member = $c->model('AccessDB::Person')->find({ id => $id }); @@ -1094,6 +1523,22 @@ sub send_membership_email: Private { $self->emailer->send($comms); } +=head2 nudge_member + +Send reminder email to member. + +Tags: Admin + +Methods: GET + +=over + +=item id (required) - Member ID + +=back + +=cut + sub nudge_member: Chained('base'): PathPart('nudge_member'): Args(1) { my ($self, $c, $id) = @_; my $member = $c->model('AccessDB::Person')->find({ id => $id }); @@ -1146,6 +1591,22 @@ sub send_reminder_email: Private { $self->emailer->send($comms); } +=head2 box_reminder + +Send box reminder email to member. + +Tags: Admin + +Methods: GET + +=over + +=item id (required) - Member ID + +=back + +=cut + sub box_reminder: Chained('base'): PathPart('box_reminder'): Args(1) { my ($self, $c, $id) = @_; my $member = $c->model('AccessDB::Person')->find({ id => $id }); @@ -1188,6 +1649,10 @@ sub send_box_reminder_email: Private { Collect and send out details about current membership to info@swindon-makerspace.org. No display! +Tags: Admin + +Methods: GET + =cut sub membership_status_update : Chained('base') :PathPart('membership_status_update') { @@ -1252,6 +1717,10 @@ sub verify_token { Plain-text list of vehicles, of currently paid-up members, sorted alphabetically. +Tags: Admin + +Methods: GET + =cut sub vehicles : Chained('logged_in') :PathPart('vehicles') { @@ -1276,6 +1745,22 @@ sub vehicles : Chained('logged_in') :PathPart('vehicles') { $c->res->body($output_str); } +=head2 membership_register + +View membership register. + +Tags: Admin + +Methods: GET + +=over + +=item at_date - Date to view register for (YYYY-MM-DD) + +=back + +=cut + sub membership_register : Chained('logged_in') :PathPart('membership_register') { my ($self, $c) = @_; @@ -1300,6 +1785,86 @@ sub end : ActionClass('RenderView') { $c->stash( current_view => 'TT'); } + +=head2 openapi_spec + +Serves the OpenAPI 3.1 specification as JSON. + +Tags: Documentation + +Methods: GET + +=cut + +sub openapi_spec :Path('/openapi') :Args(0) { + my ($self, $c) = @_; + + my $spec = AccessSystem::API::OpenAPI->generate_spec($c); + + $c->response->content_type('application/json'); + $c->response->body(encode_json($spec)); +} + +=head2 openapi_ui + +Serves Swagger UI for interactive API documentation. + +Tags: Documentation + +Methods: GET + +=cut + +sub openapi_ui :Path('/openapi/ui') :Args(0) { + my ($self, $c) = @_; + + my $spec_url = $c->uri_for('/openapi'); + + my $html = <<"HTML"; + + + + + + Swindon Makerspace API Documentation + + + + +
+ + + + + +HTML + + $c->response->content_type('text/html; charset=utf-8'); + $c->response->body($html); +} + + =head1 AUTHOR Catalyst developer diff --git a/lib/AccessSystem/API/Controller/v2.pm b/lib/AccessSystem/API/Controller/v2.pm index b83cfe2..4dbd2da 100644 --- a/lib/AccessSystem/API/Controller/v2.pm +++ b/lib/AccessSystem/API/Controller/v2.pm @@ -25,6 +25,22 @@ This one should have some sorta auth protection on it, and should log all uses. sub authenticated :Chained('/') :PathPart('v2') :CaptureArgs(0) { } +=head2 remove_ex_member_inductions + +Remove inductions for ex-members. + +Tags: Admin + +Methods: GET + +=over + +=item num_months (required) - Number of months since leaving + +=back + +=cut + sub remove_ex_member_inductions: Chained('authenticated'): PathPart('remove_ex_member_inductions'): Args(1) { my ($self, $c, $num_months) = @_; diff --git a/lib/AccessSystem/API/OpenAPI.pm b/lib/AccessSystem/API/OpenAPI.pm new file mode 100644 index 0000000..5d3b4ea --- /dev/null +++ b/lib/AccessSystem/API/OpenAPI.pm @@ -0,0 +1,693 @@ +package AccessSystem::API::OpenAPI; + +use strict; +use warnings; +use Data::Dumper; +use JSON; +use File::Find; +use File::Spec; + +our $VERSION = '1.0.0'; + +# Tag descriptions for OpenAPI spec +my %TAG_DESCRIPTIONS = ( + 'Access Control' => 'Endpoints for IoT devices and access controllers', + 'Member Registration' => 'Endpoints for member registration and profile management', + 'Authentication' => 'Login and logout endpoints', + 'Transactions' => 'Member transaction/balance endpoints', + 'Admin' => 'Administrative endpoints', + 'Telegram' => 'Telegram bot integration endpoints', + 'Documentation' => 'API documentation endpoints', +); + +# Caches for parsed POD metadata +my %_pod_cache; # Stores { params => [...], tag => '...', methods => [...] } + +=head1 NAME + +AccessSystem::API::OpenAPI - Generate OpenAPI spec from Catalyst app + +=head1 SYNOPSIS + + use AccessSystem::API::OpenAPI; + + # From a controller + my $spec = AccessSystem::API::OpenAPI->generate_spec($c); + + # From a script + my $spec = AccessSystem::API::OpenAPI->generate_spec_from_app('AccessSystem::API'); + +=head1 DESCRIPTION + +This module generates an OpenAPI 3.1 specification by introspecting +Catalyst dispatchers and parsing POD documentation from controller files. + +=cut + +sub new { + my ($class, %args) = @_; + return bless \%args, $class; +} + +=head2 generate_spec($c) + +Generate OpenAPI spec from a Catalyst context object. + +=cut + +sub generate_spec { + my ($class, $c) = @_; + + my $base_url = $c->request->base; + $base_url =~ s{/$}{}; + + return $class->_build_spec( + dispatcher => $c->dispatcher, + base_url => $base_url, + app_class => ref($c) || $c, + ); +} + +=head2 generate_spec_from_app($app_class) + +Generate OpenAPI spec by loading a Catalyst app class. + +=cut + +sub generate_spec_from_app { + my ($class, $app_class) = @_; + + eval "require $app_class" or die "Cannot load $app_class: $@"; + $app_class->setup_finalize() if $app_class->can('setup_finalize'); + + return $class->_build_spec( + dispatcher => $app_class->dispatcher, + base_url => 'http://localhost:3000', + app_class => $app_class, + ); +} + +sub _build_spec { + my ($class, %args) = @_; + + my $dispatcher = $args{dispatcher}; + my $base_url = $args{base_url}; + my $app_class = $args{app_class}; + + my $spec = { + openapi => '3.1.0', + info => { + title => 'Swindon Makerspace Access System API', + description => 'A (semi-)automated access system for registering new members and giving them access to the physical space. +It provides: +- A registration form for new members to sign up +- The ability to match RFID tokens to members +- An API for controllers (e.g., the Door) to verify if an RFID token is valid + +## Security Note + +The database stores an expected IP for each Thing controller. These are assigned as fixed IPs +to the controllers by the main network router. The API verifies that the IP of an incoming +request matches the expected IP for the claimed thing controller ID.', + version => $VERSION, + contact => { + name => 'Swindon Makerspace', + url => 'https://www.swindon-makerspace.org', + }, + }, + servers => [ + { url => $base_url, description => 'Current server' }, + { url => 'https://inside.swindon-makerspace.org', description => 'Production server' }, + ], + paths => {}, + components => { + schemas => {}, + securitySchemes => { + cookieAuth => { + type => 'apiKey', + in => 'cookie', + name => 'accesssystem_cookie', + }, + }, + }, + tags => [], + }; + + my %tags; + + # Parse POD from all controller files + $class->_parse_all_controller_pods($app_class); + + # Process dispatch types + for my $dispatch_type (@{$dispatcher->dispatch_types}) { + my $type = ref($dispatch_type); + + if ($type eq 'Catalyst::DispatchType::Path') { + my $paths = $dispatch_type->_paths || {}; + for my $path (keys %$paths) { + for my $action (@{$paths->{$path}}) { + $class->_add_path_from_action($spec, $path, $action, \%tags); + } + } + } + elsif ($type eq 'Catalyst::DispatchType::Chained') { + my $endpoints = $dispatch_type->_endpoints || []; + for my $action (@$endpoints) { + my $path = $class->_build_chained_path($dispatch_type, $action); + $class->_add_path_from_action($spec, $path, $action, \%tags, { no_args => 1 }) if $path; + } + } + } + + # Build tags array with descriptions + for my $tag (sort keys %tags) { + push @{$spec->{tags}}, { + name => $tag, + ($TAG_DESCRIPTIONS{$tag} ? (description => $TAG_DESCRIPTIONS{$tag}) : ()), + }; + } + + return $spec; +} + +sub _add_path_from_action { + my ($class, $spec, $path, $action, $tags, $opts) = @_; + $opts ||= {}; + + $path = '/' . $path unless $path =~ m{^/}; + $path =~ s{/+}{/}g; + + # Skip internal actions + return if $action->name =~ /^(auto|begin|end|default|index)$/; + return if $action->attributes->{Private}; + + my $method_name = $action->name; + + # Get metadata from POD documentation + my $tag = $class->_get_tag($method_name); + + # Skip undocumented endpoints + return if $tag eq 'Uncategorised'; + + # Handle Args at end (only if not already handled by chained path builder) + my $args = $action->attributes->{Args}; + if (!$opts->{no_args} && $args && @$args && $args->[0] ne '' && $args->[0] =~ /^\d+$/) { + for (1..$args->[0]) { + $path .= "/{arg$_}"; + } + } + + # Get tag from POD + $tags->{$tag} = 1; + + # Rename {argN} using signature parameters + my $sig_params = $class->_get_signature_params($method_name); + if ($sig_params && @$sig_params) { + $path =~ s/\{arg(\d+)\}/ $sig_params->[$1-1] ? "{" . $sig_params->[$1-1] . "}" : "{arg$1}" /ge; + } + + # Determine HTTP methods from POD or source analysis + my @methods = $class->_get_http_methods($method_name); + + for my $http_method (@methods) { + my $operation = { + operationId => $method_name . ($http_method eq 'post' ? '_post' : ''), + summary => $class->_humanize_name($method_name), + tags => [$tag], + responses => { + '200' => { description => 'Successful response' }, + }, + }; + + # Add description from POD + my $desc = $class->_get_description($method_name); + $operation->{description} = $desc if $desc; + + # Adjust operation details based on method + if ($http_method eq 'post') { + $operation->{requestBody} = { + required => JSON::true, + content => { + 'application/x-www-form-urlencoded' => { + schema => { type => 'object' } + } + } + }; + } + + # Add parameters from POD + my @path_params = ($path =~ /\{(\w+)\}/g); + my %path_params_seen; + if (@path_params) { + $operation->{parameters} = []; + + # Fetch POD params to augment path params + my $pod_params = $class->_get_query_params($method_name) || []; + my %pod_params_map = map { $_->{name} => $_ } @$pod_params; + + for my $param (@path_params) { + $path_params_seen{$param} = 1; + my $param_def = { + name => $param, + in => 'path', + required => JSON::true, + schema => { type => 'string' }, + }; + + # Merge details from POD if available + if ($pod_params_map{$param}) { + $param_def->{description} = $pod_params_map{$param}->{description}; + $param_def->{schema} = $pod_params_map{$param}->{schema}; + } + + push @{$operation->{parameters}}, $param_def; + } + } + + # Add query parameters from POD (excluding those used in path) + my $query_params = $class->_get_query_params($method_name); + if ($query_params && @$query_params) { + $operation->{parameters} ||= []; + for my $p (@$query_params) { + next if $path_params_seen{$p->{name}}; + push @{$operation->{parameters}}, $p; + } + } + + $spec->{paths}{$path} ||= {}; + $spec->{paths}{$path}{$http_method} = $operation; + } +} + +sub _build_chained_path { + my ($class, $dispatch_type, $action) = @_; + + my @parts; + my $current = $action; + my %seen; + + while ($current && !$seen{$current}++) { + my $path_part = $current->attributes->{PathPart}; + if ($path_part && @$path_part && $path_part->[0] ne '') { + unshift @parts, $path_part->[0]; + } + + my $capture = $current->attributes->{CaptureArgs}; + if ($capture && @$capture && $capture->[0] > 0) { + for (1..$capture->[0]) { + push @parts, "{capture_$_}"; + } + } + + my $parent = $current->attributes->{Chained}; + last unless $parent && @$parent; + + my $parent_action = $dispatch_type->_actions->{$parent->[0]}; + last unless $parent_action; + $current = $parent_action; + } + + my $args = $action->attributes->{Args}; + if ($args && @$args && $args->[0] ne '' && $args->[0] =~ /^\d+$/) { + for (1..$args->[0]) { + push @parts, "{arg$_}"; + } + } + + my $path = '/' . join('/', @parts); + $path =~ s{/+}{/}g; + + return $path; +} + +sub _get_http_methods { + my ($class, $name) = @_; + + # Check POD-defined methods first + if (exists $_pod_cache{$name} && $_pod_cache{$name}{methods}) { + return @{$_pod_cache{$name}{methods}}; + } + + # Fall back to source code analysis + if (exists $_pod_cache{$name} && $_pod_cache{$name}{detected_methods}) { + return @{$_pod_cache{$name}{detected_methods}}; + } + + # Default to GET + return ('get'); +} + +# Infer tag from method name patterns +sub _infer_tag { + my ($method_name) = @_; + + return 'Authentication' if $method_name =~ /login|logout|auth|token/i; + return 'Member Registration' if $method_name =~ /register|signup|member|profile/i; + return 'Tools' if $method_name =~ /tool/i; + return 'Vehicles' if $method_name =~ /vehicle/i; + return 'Transactions' if $method_name =~ /transaction|payment/i; + return 'Admin' if $method_name =~ /admin/i; + + return 'Uncategorised'; +} + +sub _get_tag { + my ($class, $name) = @_; + + # Check POD-defined tag first + if (exists $_pod_cache{$name} && $_pod_cache{$name}{tag}) { + return $_pod_cache{$name}{tag}; + } + + # Fall back to 'Uncategorised' + return 'Uncategorised'; +} + +# Default categorization for endpoints without POD Tags +sub _categorize_action_default { + return 'Uncategorised'; +} + +sub _get_description { + my ($class, $name) = @_; + return $_pod_cache{$name}{description}; +} + +sub _get_signature_params { + my ($class, $name) = @_; + return $_pod_cache{$name}{signature_params}; +} + +sub _humanize_name { + my ($class, $name) = @_; + $name =~ s/_/ /g; + return ucfirst($name); +} + +sub _get_query_params { + my ($class, $name) = @_; + return $_pod_cache{$name}{params} || []; +} + +# Parse POD from all controller files in the app +sub _parse_all_controller_pods { + my ($class, $app_class) = @_; + + return if %_pod_cache; # Already parsed + + # Find the lib directory + my $app_file = $app_class; + $app_file =~ s{::}{/}g; + $app_file .= '.pm'; + + my $lib_dir; + for my $inc (@INC) { + if (-f "$inc/$app_file") { + $lib_dir = $inc; + last; + } + } + + return unless $lib_dir; + + # Find all Controller .pm files + my @controller_files; + my $wanted = sub { + return unless /\.pm$/; + return unless $File::Find::name =~ /Controller/; + push @controller_files, $File::Find::name; + }; + + File::Find::find($wanted, $lib_dir); + + # Parse each controller file + for my $file (@controller_files) { + $class->_parse_controller_pod($file); + } +} + +# Parse POD documentation from a controller file to extract parameters +sub _parse_controller_pod { + my ($class, $file) = @_; + + open my $fh, '<', $file or return; + my $content = do { local $/; <$fh> }; + close $fh; + + # Find all POD blocks with parameters followed by sub definitions + # Pattern: =head2 name ... =item param = description ... =cut ... sub name + while ($content =~ m{ + =head2\s+(\w+) # Capture the endpoint name + (.*?) # Capture POD content + =cut\s*\n # End of POD + \s*sub\s+\1 # Sub definition with same name + }gxs) { + my ($endpoint_name, $pod_content) = ($1, $2); + + # Initialize cache entry + $_pod_cache{$endpoint_name} ||= {}; + + # Parse Tags: line with validation + if ($pod_content =~ /^Tags?:\s*(.+)$/m) { + my $tag = $1; + $tag =~ s/\s+$//; + + # Validate tag: reasonable length and no weird characters + if (length($tag) > 50) { + warn "WARNING: Tag '$tag' for $endpoint_name is very long (>50 chars)\n"; + } + if ($tag =~ /[^\w\s\-&]/) { + warn "WARNING: Tag '$tag' for $endpoint_name contains unusual characters\n"; + } + + $_pod_cache{$endpoint_name}{tag} = $tag; + } else { + # Smart default: infer from method name + my $inferred_tag = _infer_tag($endpoint_name); + warn "INFO: No tag specified for $endpoint_name, using inferred tag '$inferred_tag'\n" + if $ENV{OPENAPI_DEBUG}; + $_pod_cache{$endpoint_name}{tag} = $inferred_tag; + } + + # Parse Methods: line with validation + if ($pod_content =~ /^Methods?:\s*(.+)$/m) { + my $methods_str = $1; + $methods_str =~ s/\s+$//; + my @methods = map { lc($_) } split /[,\s]+/, $methods_str; + + # Validate HTTP methods + my @valid_methods = qw(get post put delete patch options head); + my @invalid = grep { my $m = $_; !grep { $_ eq $m } @valid_methods } @methods; + if (@invalid) { + warn "ERROR: Invalid HTTP method(s) for $endpoint_name: " . join(', ', @invalid) . "\n"; + @methods = grep { my $m = $_; grep { $_ eq $m } @valid_methods } @methods; + } + + $_pod_cache{$endpoint_name}{methods} = \@methods if @methods; + } else { + # Default will be inferred by source code analysis later + warn "INFO: No methods specified for $endpoint_name, will use source code analysis\n" + if $ENV{OPENAPI_DEBUG}; + } + + # Extract description (everything that is not Tags, Methods, or param blocks) + my $desc = $pod_content; + $desc =~ s/^Tags:.*$//mg; + $desc =~ s/^Methods:.*$//mg; + $desc =~ s/=over.*//sg; + $desc =~ s/^\s+|\s+$//g; + $_pod_cache{$endpoint_name}{description} = $desc if length($desc); + + # Parse =item entries for parameters + my @params; + while ($pod_content =~ m{=item\s+(\w+)\s*(?:\(([^)]*)\))?\s*[=-]\s*(.+?)(?=\n\n|\n=|$)}gs) { + my ($param_name, $modifiers, $description) = ($1, $2 // '', $3); + $description =~ s/\s+$//; + + # Parse modifiers like "required", "uuid", "integer", etc. + my $required = ($modifiers =~ /required/i) ? JSON::true : JSON::false; + my $type = 'string'; + my $format; + + if ($modifiers =~ /\binteger\b/i) { + $type = 'integer'; + } elsif ($modifiers =~ /\bnumber\b/i) { + $type = 'number'; + } elsif ($modifiers =~ /\bboolean\b/i) { + $type = 'boolean'; + } + + if ($modifiers =~ /\buuid\b/i) { + $format = 'uuid'; + } elsif ($modifiers =~ /\bemail\b/i) { + $format = 'email'; + } + + my $param = { + name => $param_name, + in => 'query', + required => $required, + schema => { type => $type }, + description => $description, + }; + $param->{schema}{format} = $format if $format; + + push @params, $param; + } + + $_pod_cache{$endpoint_name}{params} = \@params if @params; + } + + # Source code analysis: detect HTTP methods from subroutine implementations + $class->_analyze_controller_methods($content); +} + +# Analyze controller source code to detect HTTP methods +sub _analyze_controller_methods { + my ($class, $content) = @_; + + # Find all subs with attributes (controller actions) + my @subs = $content =~ /^sub\s+(\w+)\s*:\s*[^\n]+/gm; + + for my $sub_name (@subs) { + + # Initialize cache entry + $_pod_cache{$sub_name} ||= {}; + + # Extract the subroutine body using a simple heuristic + # Look for the sub definition and capture until we see the next sub or end + my ($sub_body) = $content =~ /sub\s+$sub_name\s*:[^\n]+\{(.{1,3000}?)(?=\nsub\s+\w|\n__(?:END|DATA)__|$)/s; + next unless $sub_body; + + # Extract Parameter Names from Signature + if ($sub_body =~ /my\s*\(\s*\$self\s*,\s*\$c\s*(?:,\s*([^)]+))?\)\s*=\s*\@_/) { + my $args_str = $1; + if ($args_str) { + my @arg_names = split /\s*,\s*/, $args_str; + @arg_names = map { s/^[\$\@\%]//; $_ } @arg_names; + # print STDERR "DEBUG: Found signature for $sub_name: @arg_names\n"; + $_pod_cache{$sub_name}{signature_params} = \@arg_names if @arg_names; + } + } + + # Skip method detection if already defined + next if exists $_pod_cache{$sub_name} && $_pod_cache{$sub_name}{methods}; + + # Detect POST-specific patterns + my $has_post_patterns = 0; + my $has_form_render = 0; + + # POST-only patterns (body params, method check, callbacks, db writes) + $has_post_patterns = 1 if $sub_body =~ /->body_params/ + || $sub_body =~ /->method\s*eq\s*['"]POST['"]/i + || $sub_body =~ /connection_token/ + || $sub_body =~ /->(?:txn_do|create|update|delete)/; # DB writes imply POST + + # Form rendering patterns (indicates GET for display) + $has_form_render = 1 if $sub_body =~ /stash\s*\([^)]*template\s*=>/i + || $sub_body =~ /\$form->/; + + # Determine methods based on detected patterns + if ($has_form_render && $has_post_patterns) { + # Form with both display and submit + $_pod_cache{$sub_name}{detected_methods} = ['get', 'post']; + } elsif ($has_form_render) { + # Form display + submit pattern + $_pod_cache{$sub_name}{detected_methods} = ['get', 'post']; + } elsif ($has_post_patterns) { + # POST-only (API endpoint, callback) + $_pod_cache{$sub_name}{detected_methods} = ['post']; + } + # Otherwise, default to GET (handled in _get_http_methods) + } +} + +1; + +__END__ + +=head1 POD DOCUMENTATION FORMAT + +To document an endpoint, use this format in the controller: + + =head2 endpoint_name + + Description of the endpoint. + + Tags: Access Control + + Methods: GET, POST + + =over + + =item param_name (modifiers) - Description + + =back + + =cut + + sub endpoint_name ... + +=head2 SUPPORTED FIELDS + +=over + +=item B (optional) + +The OpenAPI tag/category for this endpoint. +If not specified, a default categorization is used. +Example: C + +=item B (optional) + +HTTP methods this endpoint accepts. Comma or space separated. +If not specified, defaults to GET. +Example: C or C + +=back + +=head2 PARAMETER MODIFIERS + +Use modifiers in parentheses after the parameter name: + +=over + +=item * C - marks parameter as required + +=item * C - adds format: uuid + +=item * C - adds format: email + +=item * C - sets type to integer + +=item * C - sets type to number + +=item * C - sets type to boolean + +=back + +=head2 COMPLETE EXAMPLE + + =head2 park + + Parks a vehicle for a member identified by their token. + + Tags: Access Control + + Methods: GET + + =over + + =item token (required) - Access token ID of the member + + =item thing (required, uuid) - GUID of the parking controller + + =back + + =cut + + sub park : Chained('base') : PathPart('park') : Args(0) { + ... + } + +=cut diff --git a/lib/AccessSystem/Schema/Result/Person.pm b/lib/AccessSystem/Schema/Result/Person.pm index 80a0783..f64a8b4 100644 --- a/lib/AccessSystem/Schema/Result/Person.pm +++ b/lib/AccessSystem/Schema/Result/Person.pm @@ -265,12 +265,16 @@ sub is_valid { my $is_paid; - if(!$self->parent) { + # Check parent_id column directly to avoid relationship resolution issues + # on newly created objects that haven't been stored yet + my $parent_id = $self->get_column('parent_id'); + if(!$parent_id) { $is_paid = $self->payments_rs->search({ paid_on_date => { '<=' => $date_str }, expires_on_date => { '>=' => $date_str }, })->count; } else { + # Only call parent relationship if we have a parent_id return $self->parent->is_valid; } @@ -305,7 +309,8 @@ sub bank_ref { sub normal_dues { my ($self) = @_; - return 0 if $self->parent; + # Check parent_id column directly to avoid relationship resolution issues + return 0 if $self->get_column('parent_id'); if ($self->is_donor) { return 0; @@ -940,7 +945,8 @@ sub update_door_access { # This entry should exist, but covid policy may have removed it.. my $door = $self->result_source->schema->the_door(); - my $door_allowed = $self->allowed->find_or_create({ tool_id => $door->id }); + # is_admin required: allowed.is_admin has a NOT NULL constraint + my $door_allowed = $self->allowed->find_or_create({ tool_id => $door->id, is_admin => 0 }); $door_allowed->update({ pending_acceptance => 'false', accepted_on => DateTime->now()}); } diff --git a/script/generate_openapi.pl b/script/generate_openapi.pl new file mode 100644 index 0000000..dba4b1b --- /dev/null +++ b/script/generate_openapi.pl @@ -0,0 +1,157 @@ +#!/usr/bin/env perl +# +# generate_openapi.pl - Generate OpenAPI 3.1 spec from Catalyst app introspection +# +# Usage: +# CATALYST_HOME=$PWD carton exec perl script/generate_openapi.pl > openapi.yaml +# CATALYST_HOME=$PWD carton exec perl script/generate_openapi.pl --json > openapi.json +# +use strict; +use warnings; +use FindBin; +use lib "$FindBin::Bin/../lib"; +use Cwd qw(getcwd); +use Getopt::Long; +use JSON; + +# Set CATALYST_HOME if not already set +$ENV{CATALYST_HOME} ||= getcwd(); + +# Command line options +my $output_json = 0; +my $help = 0; +GetOptions( + 'json' => \$output_json, + 'help' => \$help, +) or die "Error in command line arguments\n"; + +if ($help) { + print <<'USAGE'; +Usage: generate_openapi.pl [OPTIONS] + +Generate an OpenAPI 3.1 specification from the Catalyst application. + +Options: + --json Output JSON instead of YAML + --help Show this help message + +USAGE + exit 0; +} + +# Load the OpenAPI module +use AccessSystem::API::OpenAPI; + +# Load the Catalyst app +require AccessSystem::API; +AccessSystem::API->setup_finalize(); + +# Generate the spec using the centralized module +my $spec = AccessSystem::API::OpenAPI->generate_spec_from_app('AccessSystem::API'); + +# Output +if ($output_json) { + print JSON->new->pretty->canonical->encode($spec); +} else { + print_yaml($spec); +} + +# Simple YAML output (avoiding YAML::XS dependency) +sub print_yaml { + my ($data, $indent) = @_; + $indent //= 0; + + my $prefix = ' ' x $indent; + + if (ref $data eq 'HASH') { + for my $key (sort keys %$data) { + my $value = $data->{$key}; + if (!ref $value) { + if (!defined $value) { + print "${prefix}${key}: null\n"; + } elsif ($value =~ /^[\d.]+$/ && $value !~ /^0\d/) { + print "${prefix}${key}: $value\n"; + } elsif ($value eq 'true' || $value eq 'false') { + print "${prefix}${key}: $value\n"; + } else { + # Quote strings that need it + if ($value =~ /[:[\]{}&#!|>'"%@`#,]/ || $value =~ /^\s/ || $value =~ /\s$/ || $value eq '') { + $value =~ s/'/\\'/g; + print "${prefix}${key}: '$value'\n"; + } else { + print "${prefix}${key}: $value\n"; + } + } + } elsif (ref $value eq 'ARRAY' && @$value == 0) { + print "${prefix}${key}: []\n"; + } elsif (ref $value eq 'HASH' && keys(%$value) == 0) { + print "${prefix}${key}: {}\n"; + } else { + print "${prefix}${key}:\n"; + print_yaml($value, $indent + 1); + } + } + } elsif (ref $data eq 'ARRAY') { + for my $item (@$data) { + if (!ref $item) { + print "${prefix}- $item\n"; + } elsif (ref $item eq 'HASH' && keys(%$item) <= 2) { + # Compact format for small hashes + my @pairs; + for my $k (sort keys %$item) { + my $v = $item->{$k}; + if (!ref $v) { + push @pairs, "$k: $v"; + } + } + if (@pairs == keys(%$item)) { + print "${prefix}- { " . join(', ', @pairs) . " }\n"; + } else { + print "${prefix}-\n"; + print_yaml($item, $indent + 1); + } + } else { + print "${prefix}-\n"; + print_yaml($item, $indent + 1); + } + } + } elsif (ref $data eq 'JSON::PP::Boolean' || ref $data eq 'JSON::XS::Boolean') { + print $data ? 'true' : 'false'; + } +} + +__END__ + +=head1 NAME + +generate_openapi.pl - Generate OpenAPI specification from Catalyst app + +=head1 SYNOPSIS + + # Generate YAML (default) + CATALYST_HOME=$PWD carton exec perl script/generate_openapi.pl > openapi.yaml + + # Generate JSON + CATALYST_HOME=$PWD carton exec perl script/generate_openapi.pl --json > openapi.json + +=head1 DESCRIPTION + +This script uses the AccessSystem::API::OpenAPI module to generate an OpenAPI +3.1 specification by introspecting the Catalyst application's dispatcher and +parsing POD documentation from controller files. + +=head1 OPTIONS + +=over 4 + +=item --json + +Output JSON instead of YAML + +=item --help + +Show help message + +=back + +=cut diff --git a/t/01app.t b/t/01app.t index a824f04..25773a1 100644 --- a/t/01app.t +++ b/t/01app.t @@ -2,9 +2,32 @@ use strict; use warnings; use Test::More; +use Cwd qw(getcwd); + +# Set CATALYST_HOME so config is loaded correctly +$ENV{CATALYST_HOME} = getcwd(); + +use lib 't/lib'; +use AccessSystem::Schema; +use AccessSystem::Fixtures; + +# Use the same database that the config specifies +# The test config uses dbi:SQLite:test_db.db +my $testdb = 'test_db.db'; +unlink $testdb if -e $testdb; # Start fresh + +# Deploy schema and create fixtures +my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb"); +$schema->deploy(); +AccessSystem::Fixtures::create_tiers($schema); use Catalyst::Test 'AccessSystem::API'; -ok( request('/register')->is_success, 'Request should succeed' ); +# Test that the app loads and responds to requests +ok( request('/login')->is_success, 'Request to /login should succeed' ); +ok( request('/register')->is_success, 'Request to /register should succeed' ); + +# Clean up +unlink($testdb); done_testing(); diff --git a/t/ResultSetPerson.t b/t/ResultSetPerson.t index 198b5ce..e1347cd 100644 --- a/t/ResultSetPerson.t +++ b/t/ResultSetPerson.t @@ -82,8 +82,8 @@ my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb"); my $comms_count = 0; my $testee = AccessSystem::Fixtures::create_person($schema, payment => $payment_amount); $testee->create_related('tokens', { id => '12345678', type => 'test token' }); - # The Door so that Result::Person::update_door_access works - my $thing = $schema->resultset('Tool')->create({ name => 'The Door', assigned_ip => '10.0.0.1', requires_induction => 1, team => 'Who knows' }); + # The Door so that Result::Person::update_door_access works (fixtures may have already created it) + my $thing = $schema->resultset('Tool')->find_or_create({ name => 'The Door', assigned_ip => '10.0.0.1', requires_induction => 1, team => 'Who knows' }); my $allowed = $testee->create_related('allowed', { tool => $thing, is_admin => 0}); $allowed->discard_changes(); $allowed->update({ pending_acceptance => 0 }); @@ -233,4 +233,3 @@ my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb"); done_testing; - diff --git a/t/lib/AccessSystem/Fixtures.pm b/t/lib/AccessSystem/Fixtures.pm new file mode 100644 index 0000000..4b358f2 --- /dev/null +++ b/t/lib/AccessSystem/Fixtures.pm @@ -0,0 +1,183 @@ +package AccessSystem::Fixtures; + +use strict; +use warnings; + +use DateTime; + +=head1 NAME + +AccessSystem::Fixtures - Test fixture helpers for AccessSystem tests + +=head1 SYNOPSIS + + use lib 't/lib'; + use AccessSystem::Fixtures; + + my $schema = AccessSystem::Schema->connect("dbi:SQLite:test.db"); + $schema->deploy(); + + # Create membership tiers + AccessSystem::Fixtures::create_tiers($schema); + + # Create a test person + my $person = AccessSystem::Fixtures::create_person($schema, + name => 'Test User', + dob => '1990-01', + ); + +=head1 DESCRIPTION + +Test fixture helpers for unit tests. Provides functions to create +test data in the database. + +=cut + +my $person_counter = 0; + +=head2 create_tiers($schema) + +Create the standard membership tiers used for testing. + +=cut + +sub create_tiers { + my ($schema) = @_; + + my @tiers = ( + { + id => 1, + name => 'Other Hackspace', + description => 'Member of another hackspace/makerspace', + price => 500, # ยฃ5 + concessions_allowed => 0, + in_use => 1, + restrictions => '{}', + }, + { + id => 2, + name => 'Standard', + description => 'Standard full membership', + price => 2500, # ยฃ25 + concessions_allowed => 1, + in_use => 1, + restrictions => '{}', + }, + { + id => 3, + name => 'Student', + description => 'Student membership (requires proof)', + price => 1250, # ยฃ12.50 + concessions_allowed => 0, + in_use => 1, + restrictions => '{}', + }, + { + id => 4, + name => 'Weekend', + description => 'Weekend access only', + price => 1500, # ยฃ15 + concessions_allowed => 1, + in_use => 1, + restrictions => '{"times":[{"from":"6:00:00","to":"7:23:59"}]}', + }, + { + id => 5, + name => "Men's Shed", + description => "Men's Shed membership", + price => 1000, # ยฃ10 + concessions_allowed => 0, + in_use => 1, + restrictions => '{}', + }, + { + id => 6, + name => 'Donation', + description => 'Donor only membership (no access)', + price => 0, + concessions_allowed => 0, + in_use => 1, + restrictions => '{}', + }, + ); + + for my $tier_data (@tiers) { + $schema->resultset('Tier')->update_or_create($tier_data); + } + + # Create 'The Door' tool - required by update_door_access() + $schema->resultset('Tool')->update_or_create({ + id => 1, + name => 'The Door', + assigned_ip => '10.0.0.1', + requires_induction => 0, + team => 'Everyone', + }); + + return; +} + +=head2 create_person($schema, %args) + +Create a test person. Returns the Person result object. + +Accepts optional arguments: + - name: Person name (defaults to 'Test Person N') + - email: Email address (defaults to 'test{N}@example.com') + - dob: Date of birth as 'YYYY-MM' (defaults to '1980-01') + - address: Address (defaults to '123 Test Street') + - c_rate: Concessionary rate override (e.g., 'student', 'legacy') + - tier_id: Tier ID (defaults to 2 = Standard) + - payment: Payment override (in pence) + - member_of_other_hackspace: Boolean (defaults to 0) + +=cut + +sub create_person { + my ($schema, %args) = @_; + + $person_counter++; + + my $person_data = { + name => $args{name} // "Test Person $person_counter", + email => $args{email} // "test$person_counter\@example.com", + dob => $args{dob} // '1980-01', + address => $args{address} // '123 Test Street, Testville, TE5 7ST', + tier_id => $args{tier_id} // 2, # Default to Standard tier + }; + + # Handle concessionary rate override + if (defined $args{c_rate}) { + $person_data->{concessionary_rate_override} = $args{c_rate}; + } + + # Handle payment override + if (defined $args{payment}) { + $person_data->{payment_override} = $args{payment}; + } + + my $person = $schema->resultset('Person')->create($person_data); + + return $person; +} + +=head2 reset_counter() + +Reset the person counter. Useful between test files. + +=cut + +sub reset_counter { + $person_counter = 0; + return; +} + +1; + +__END__ + +=head1 AUTHOR + +AccessSystem test fixtures + +=cut diff --git a/t/openapi_validation.t b/t/openapi_validation.t new file mode 100644 index 0000000..5ac6b77 --- /dev/null +++ b/t/openapi_validation.t @@ -0,0 +1,135 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use Test::More; +use Cwd qw(getcwd); +use JSON; + +# Set CATALYST_HOME so config is loaded correctly +$ENV{CATALYST_HOME} = getcwd(); + +# Use Catalyst::Test to ensure the app is loaded +use Catalyst::Test 'AccessSystem::API'; + +# Load the OpenAPI module +use_ok('AccessSystem::API::OpenAPI'); + +# Load the app context to get the Catalyst instance +my $app = AccessSystem::API->new(); + +# Generate OpenAPI spec using the app's dispatcher +my $spec = AccessSystem::API::OpenAPI->generate_spec($app); + +# Basic spec structure tests +ok($spec, "OpenAPI spec generated"); +ok($spec->{openapi}, "Spec has openapi version"); +ok($spec->{info}, "Spec has info section"); +ok($spec->{paths}, "Spec has paths"); +ok($spec->{components}, "Spec has components"); + +# Count endpoints +my $endpoint_count = scalar keys %{$spec->{paths}}; +cmp_ok($endpoint_count, '>', 0, "Has at least one endpoint"); +diag("Testing $endpoint_count endpoints"); + +# Track validation issues +my @warnings; +my @errors; + +# Test each endpoint +for my $path (sort keys %{$spec->{paths}}) { + for my $method (sort keys %{$spec->{paths}{$path}}) { + my $op = $spec->{paths}{$path}{$method}; + my $endpoint_id = "$method $path"; + + # Required fields + ok($op->{operationId}, "$endpoint_id has operationId") + or push @errors, "$endpoint_id missing operationId"; + ok($op->{summary}, "$endpoint_id has summary") + or push @errors, "$endpoint_id missing summary"; + ok($op->{tags} && ref($op->{tags}) eq 'ARRAY', "$endpoint_id has tags array") + or push @errors, "$endpoint_id missing/invalid tags"; + ok($op->{responses}, "$endpoint_id has responses") + or push @errors, "$endpoint_id missing responses"; + + # Tag validation (sanity checks) + if ($op->{tags} && @{$op->{tags}}) { + my $tag = $op->{tags}[0]; + if (length($tag) > 50) { + push @warnings, "$endpoint_id tag is very long (>50 chars): $tag"; + } + if ($tag =~ /[^\w\s\-&]/) { + push @warnings, "$endpoint_id tag contains unusual characters: $tag"; + } + } + + # HTTP method validation + ok($method =~ /^(get|post|put|delete|patch|options|head)$/i, + "$endpoint_id has valid HTTP method") + or push @errors, "$endpoint_id has invalid method: $method"; + + # Parameter validation + if ($op->{parameters}) { + ok(ref($op->{parameters}) eq 'ARRAY', + "$endpoint_id parameters is an array") + or next; + + for my $param (@{$op->{parameters}}) { + # Required parameter fields + ok($param->{name}, "$endpoint_id parameter has name") + or push @errors, "$endpoint_id has parameter without name"; + + ok($param->{in}, "$endpoint_id parameter '$param->{name}' has 'in' field") + or push @errors, "$endpoint_id parameter $param->{name} missing 'in'"; + + ok(exists $param->{required}, + "$endpoint_id parameter '$param->{name}' has 'required' field") + or push @warnings, "$endpoint_id parameter $param->{name} missing 'required'"; + + ok($param->{schema}, "$endpoint_id parameter '$param->{name}' has schema") + or push @errors, "$endpoint_id parameter $param->{name} missing schema"; + + # Validate 'in' values + if ($param->{in}) { + ok($param->{in} =~ /^(query|path|header|cookie)$/, + "$endpoint_id parameter '$param->{name}' has valid 'in' value") + or push @errors, "$endpoint_id parameter $param->{name} has invalid 'in': $param->{in}"; + } + + # Path parameters must be required + if ($param->{in} && $param->{in} eq 'path') { + ok($param->{required}, + "$endpoint_id path parameter '$param->{name}' is required") + or push @errors, "$endpoint_id path param $param->{name} not marked required"; + } + } + } + + # Check for description (optional but recommended) + if (!$op->{description}) { + push @warnings, "$endpoint_id missing description"; + } + } +} + +# Report validation issues +if (@errors) { + diag("\n=== ERRORS ==="); + diag(" $_") for @errors; +} + +if (@warnings) { + diag("\n=== WARNINGS ==="); + diag(" $_") for @warnings; +} + +# Summary +diag("\n=== SUMMARY ==="); +diag("Endpoints: $endpoint_count"); +diag("Errors: " . scalar(@errors)); +diag("Warnings: " . scalar(@warnings)); + +# Fail if there are errors, but warnings are OK +is(scalar(@errors), 0, "No validation errors"); + +done_testing(); diff --git a/test-deploy/README.md b/test-deploy/README.md new file mode 100644 index 0000000..032adf7 --- /dev/null +++ b/test-deploy/README.md @@ -0,0 +1,32 @@ +# Local Production Test Environment + +This directory contains configuration and scripts to run the production Docker container locally with a full PostgreSQL database. + +## ๐Ÿš€ Quick Start + +1. **Run the start script:** + ```bash + ./start.sh + ``` + This will: + - Build the production image + - Start containers (App + Postgres 17) + - Deploy the database schema + - Seed test data (Tiers + "The Door") + +2. **Access the App:** + - [http://localhost:3000/login](http://localhost:3000/login) + - [http://localhost:3000/register](http://localhost:3000/register) + +## ๐Ÿ›‘ Stop & Cleanup + +```bash +docker compose down -v +``` + +## ๐Ÿ“‚ Structure + +- **`docker-compose.yaml`**: Orchestrates valid Prod container + Postgres DB. +- **`config/accesssystem_api_local.conf`**: Test-specific config (connects to local DB, dummy keys). +- **`seed_data.sql`**: Initial data needed for the app to function (Membership Tiers, Tools). +- **`start.sh`**: Automates build, deploy, and seed steps. diff --git a/test-deploy/docker-compose.yaml b/test-deploy/docker-compose.yaml new file mode 100644 index 0000000..e725cff --- /dev/null +++ b/test-deploy/docker-compose.yaml @@ -0,0 +1,34 @@ +services: + db: + image: postgres:17 + environment: + POSTGRES_USER: access + POSTGRES_PASSWORD: accesstest + POSTGRES_DB: accesssystem + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U access -d accesssystem" ] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: + context: .. + dockerfile: Dockerfile + target: production + ports: + - "3000:3000" + environment: + CATALYST_HOME: /app + volumes: + - ./config/accesssystem_api_local.conf:/app/accesssystem_api_local.conf:ro + - ../accesssystem_api.conf.example:/app/accesssystem_api.conf:ro + depends_on: + db: + condition: service_healthy + command: [ "carton", "exec", "perl", "script/accesssystem_api_server.pl", "--port", "3000", "--host", "0.0.0.0" ] + +volumes: + postgres_data: diff --git a/test-deploy/seed_data.sql b/test-deploy/seed_data.sql new file mode 100644 index 0000000..b2d1c91 --- /dev/null +++ b/test-deploy/seed_data.sql @@ -0,0 +1,10 @@ +INSERT INTO tiers (id, name, description, price, concessions_allowed, in_use, restrictions) VALUES +(1, 'Other Hackspace', 'Member of another hackspace/makerspace', 500, false, true, '{}'), +(2, 'Standard', 'Standard full membership', 2500, true, true, '{}'), +(3, 'Student', 'Student membership (requires proof)', 1250, false, true, '{}'), +(4, 'Weekend', 'Weekend access only', 1500, true, true, '{"times":[{"from":"6:00:00","to":"7:23:59"}]}'), +(5, 'Men''s Shed', 'Men''s Shed membership', 1000, false, true, '{}'), +(6, 'Donation', 'Donor only membership (no access)', 0, false, true, '{}'); + +INSERT INTO tools (id, name, assigned_ip, requires_induction, team) VALUES +('09637E38-F469-11F0-A94B-FD08D99F0D81', 'The Door', '10.0.0.1', false, 'Everyone'); diff --git a/test-deploy/start.sh b/test-deploy/start.sh new file mode 100755 index 0000000..b63c572 --- /dev/null +++ b/test-deploy/start.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -e + +# Ensure we're in the right directory +cd "$(dirname "$0")" + +# Check for required tools +if ! command -v docker &> /dev/null; then + echo "โŒ Error: 'docker' is not installed or not in PATH." + exit 1 +fi + +if ! docker compose version &> /dev/null; then + echo "โŒ Error: 'docker compose' is not available." + echo " Ensure you have a recent version of Docker Desktop installed." + exit 1 +fi + +echo "๐Ÿš€ Starting Production Container Test Environment..." + +# Create config if it doesn't exist +if [ ! -f config/accesssystem_api_local.conf ]; then + echo "โš™๏ธ Creating default test configuration..." + mkdir -p config + cat > config/accesssystem_api_local.conf < + + dsn dbi:Pg:dbname=accesssystem;host=db + user access + password accesstest + + + +# Dummy reCAPTCHA keys (Google test keys) + + site_key 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI + secret_key 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe + + +# Dummy OneAll settings + + subdomain test + domain test.api.oneall.com + public_key test-public-key + private_key test-private-key + + +# Cookie settings + + name access_system_test + mac_secret docker-test-cookie-secret + + + + namespace accesssystem + + +# Dummy Sendinblue/Brevo settings + + api-key dummy-test-api-key + + +base_url http://localhost:3000/accesssystem/ +EOL +fi + +# Build (using parent context) +echo "๐Ÿ“ฆ Building production image..." +docker build --target production -t accesssystem:latest .. + +# Start containers +echo "๐Ÿ”„ Starting containers..." +docker compose up -d + +# Wait for DB +# Wait for DB to be healthy +echo "โณ Waiting for Database to be ready..." +RETRIES=30 +until docker compose exec -T db pg_isready -U access -d accesssystem > /dev/null 2>&1; do + ((RETRIES--)) + if [ $RETRIES -le 0 ]; then + echo "โŒ Database failed to start in time." + exit 1 + fi + echo "zzz... waiting for database ($RETRIES retries left)" + sleep 2 +done +echo "โœ… Database is up!" + +# Deploy Schema +echo "๐Ÿ“œ Deploying Schema (v18.0 PostgreSQL)..." +docker compose exec -T db psql -U access -d accesssystem < ../sql/AccessSystem-Schema-18.0-PostgreSQL.sql + +# Seed Data +echo "๐ŸŒฑ Seeding Data..." +docker compose exec -T db psql -U access -d accesssystem < seed_data.sql + +echo "โœ… Environment Ready!" +echo "โžก๏ธ Login: http://localhost:3000/login" +echo "โžก๏ธ Register: http://localhost:3000/register" +echo "" +echo "To stop: docker compose down -v"