Bladeren bron

Initial commit: CakePHP app with Docker stack, territories, and address lookup

Docker Compose stack (PHP/Composer, Apache web server, MySQL, Adminer),
a scaffolded CakePHP 5 app with the Geo plugin (Google geocoding,
MapTiler tiles), a Territories feature for drawing/editing/deleting
polygons on a Leaflet map, and an OpenStreetMap Overpass integration
that finds home addresses inside a territory's polygon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 6 dagen geleden
commit
19ea4bf43d
100 gewijzigde bestanden met toevoegingen van 11957 en 0 verwijderingen
  1. +9
    -0
      .env.example
  2. +2
    -0
      .gitignore
  3. +79
    -0
      README.md
  4. +26
    -0
      app/.editorconfig
  5. +36
    -0
      app/.gitattributes
  6. +23
    -0
      app/.github/ISSUE_TEMPLATE.md
  7. +14
    -0
      app/.github/PULL_REQUEST_TEMPLATE.md
  8. +12
    -0
      app/.github/dependabot.yml
  9. +81
    -0
      app/.github/workflows/ci.yml
  10. +29
    -0
      app/.github/workflows/stale.yml
  11. +52
    -0
      app/.gitignore
  12. +12
    -0
      app/.htaccess
  13. +58
    -0
      app/README.md
  14. +47
    -0
      app/bin/bash_completion.sh
  15. +75
    -0
      app/bin/cake
  16. +27
    -0
      app/bin/cake.bat
  17. +10
    -0
      app/bin/cake.php
  18. +61
    -0
      app/composer.json
  19. +6719
    -0
      app/composer.lock
  20. +41
    -0
      app/config/.env.example
  21. +38
    -0
      app/config/Migrations/20260727204806_CreateTerritories.php
  22. +70
    -0
      app/config/Migrations/20260727210606_CreateAddresses.php
  23. BIN
      app/config/Migrations/schema-dump-default.lock
  24. +513
    -0
      app/config/app.php
  25. +97
    -0
      app/config/app_local.example.php
  26. +237
    -0
      app/config/bootstrap.php
  27. +94
    -0
      app/config/paths.php
  28. +36
    -0
      app/config/plugins.php
  29. +96
    -0
      app/config/routes.php
  30. +18
    -0
      app/config/schema/i18n.sql
  31. +15
    -0
      app/config/schema/sessions.sql
  32. +16
    -0
      app/index.php
  33. +10
    -0
      app/phpcs.xml
  34. +7
    -0
      app/phpstan.neon
  35. +36
    -0
      app/phpunit.xml.dist
  36. +0
    -0
      app/plugins/.gitkeep
  37. +15
    -0
      app/psalm.xml
  38. +0
    -0
      app/resources/.gitkeep
  39. +131
    -0
      app/src/Application.php
  40. +260
    -0
      app/src/Console/Installer.php
  41. +52
    -0
      app/src/Controller/AppController.php
  42. +0
    -0
      app/src/Controller/Component/.gitkeep
  43. +70
    -0
      app/src/Controller/ErrorController.php
  44. +73
    -0
      app/src/Controller/PagesController.php
  45. +137
    -0
      app/src/Controller/TerritoriesController.php
  46. +58
    -0
      app/src/Middleware/HostHeaderMiddleware.php
  47. +0
    -0
      app/src/Model/Behavior/.gitkeep
  48. +47
    -0
      app/src/Model/Entity/Address.php
  49. +34
    -0
      app/src/Model/Entity/Territory.php
  50. +121
    -0
      app/src/Model/Table/AddressesTable.php
  51. +82
    -0
      app/src/Model/Table/TerritoriesTable.php
  52. +153
    -0
      app/src/Service/OverpassService.php
  53. +46
    -0
      app/src/View/AjaxView.php
  54. +42
    -0
      app/src/View/AppView.php
  55. +0
    -0
      app/src/View/Cell/.gitkeep
  56. +0
    -0
      app/src/View/Helper/.gitkeep
  57. +26
    -0
      app/templates/Error/error400.php
  58. +36
    -0
      app/templates/Error/error500.php
  59. +21
    -0
      app/templates/Pages/home.php
  60. +220
    -0
      app/templates/Territories/index.php
  61. +1
    -0
      app/templates/cell/.gitkeep
  62. +15
    -0
      app/templates/element/flash/default.php
  63. +11
    -0
      app/templates/element/flash/error.php
  64. +11
    -0
      app/templates/element/flash/info.php
  65. +11
    -0
      app/templates/element/flash/success.php
  66. +11
    -0
      app/templates/element/flash/warning.php
  67. +8
    -0
      app/templates/element/footer.php
  68. +38
    -0
      app/templates/element/header.php
  69. +22
    -0
      app/templates/email/html/default.php
  70. +18
    -0
      app/templates/email/text/default.php
  71. +17
    -0
      app/templates/layout/ajax.php
  72. +52
    -0
      app/templates/layout/default.php
  73. +25
    -0
      app/templates/layout/email/html/default.php
  74. +17
    -0
      app/templates/layout/email/text/default.php
  75. +39
    -0
      app/templates/layout/error.php
  76. +36
    -0
      app/tests/Fixture/AddressesFixture.php
  77. +31
    -0
      app/tests/Fixture/TerritoriesFixture.php
  78. +88
    -0
      app/tests/TestCase/ApplicationTest.php
  79. +0
    -0
      app/tests/TestCase/Controller/Component/.gitkeep
  80. +113
    -0
      app/tests/TestCase/Controller/PagesControllerTest.php
  81. +0
    -0
      app/tests/TestCase/Model/Behavior/.gitkeep
  82. +76
    -0
      app/tests/TestCase/Model/Table/AddressesTableTest.php
  83. +64
    -0
      app/tests/TestCase/Model/Table/TerritoriesTableTest.php
  84. +0
    -0
      app/tests/TestCase/View/Helper/.gitkeep
  85. +60
    -0
      app/tests/bootstrap.php
  86. +4
    -0
      app/tests/schema.sql
  87. +5
    -0
      app/webroot/.htaccess
  88. +262
    -0
      app/webroot/css/app.css
  89. +301
    -0
      app/webroot/css/cake.css
  90. +80
    -0
      app/webroot/css/fonts.css
  91. +75
    -0
      app/webroot/css/home.css
  92. +9
    -0
      app/webroot/css/milligram.min.css
  93. +8
    -0
      app/webroot/css/normalize.min.css
  94. BIN
      app/webroot/favicon.ico
  95. +51
    -0
      app/webroot/font/Raleway-License.txt
  96. BIN
      app/webroot/font/cakedingbats-webfont.eot
  97. +78
    -0
      app/webroot/font/cakedingbats-webfont.svg
  98. BIN
      app/webroot/font/cakedingbats-webfont.ttf
  99. BIN
      app/webroot/font/cakedingbats-webfont.woff
  100. BIN
      app/webroot/font/cakedingbats-webfont.woff2

+ 9
- 0
.env.example Bestand weergeven

@@ -0,0 +1,9 @@
# Copy this file to .env and fill in real values. .env is gitignored.

# Google Cloud API key with the Geocoding API enabled.
# https://console.cloud.google.com/google/maps-apis/credentials
GOOGLE_MAPS_API_KEY=

# MapTiler API key, used for map tile rendering.
# https://cloud.maptiler.com/account/keys/
MAPTILER_API_KEY=

+ 2
- 0
.gitignore Bestand weergeven

@@ -0,0 +1,2 @@
.env
.claude/

+ 79
- 0
README.md Bestand weergeven

@@ -0,0 +1,79 @@
# CakePHP Docker Stack

Two containers:

- **composer** — PHP CLI + Composer (with the `intl`, `zip`, `mbstring` extensions CakePHP needs). Used on demand to scaffold/manage the app; it isn't a long-running service.
- **web** — Apache + PHP (`intl`, `pdo_mysql`, `mbstring`, `zip`, `gd`, `mod_rewrite`) serving `./app/webroot`.
- **db** — MySQL 8.4, database `cakephp`, user `cakephp` / password `cakephp` (root password `root`). Data persists in the `db_data` named volume. Port 3306 is published to the host if you want to connect with a GUI client (Workbench, TablePlus, etc.).

`composer` and `web` share the `./app` directory as the CakePHP application root. `app/config/app_local.php` is already pointed at the `db` service (host `db`, database/user/password `cakephp`).

## First-time setup

The app was scaffolded with:

```bash
docker compose build
docker compose run --rm --entrypoint sh composer -c \
"composer create-project --prefer-dist cakephp/app /tmp/app --no-interaction && cp -a /tmp/app/. /var/www/html/"
```

(Scaffolding via `/tmp` inside the container and copying out avoids a Composer bug deleting temp files directly on a Windows bind mount.)

## Usage

Start the site:

```bash
docker compose up -d web
```

Visit http://localhost:8080

Run Composer commands against the app (e.g. installing a plugin):

```bash
docker compose run --rm composer require cakephp/authentication
```

Run `bin/cake` console commands via the web container:

```bash
docker compose exec web php bin/cake.php bake controller Articles
```

## Database

Bring the DB up alongside the app:

```bash
docker compose up -d db web
```

Run migrations/bake against it once you have tables:

```bash
docker compose exec web php bin/cake.php migrations migrate
```

Connect from a host GUI client at `127.0.0.1:3306` using `cakephp` / `cakephp` (or `root` / `root`).

## Geo (dereuromark/cakephp-geo)

The `Geo` plugin is installed and loaded (`app/src/Application.php`). Setup:

- **Geocoding** (address <-> lat/lng) uses the Google Maps provider, configured in `app/config/app.php` under `Geocoder`.
- **Map tiles** (`LeafletHelper`) are served by MapTiler, configured under `Leaflet.tileLayer`.

Both need API keys, passed into the `web` container as environment variables:

```bash
cp .env.example .env
# edit .env and set GOOGLE_MAPS_API_KEY and MAPTILER_API_KEY
docker compose up -d web
```

- Google Maps API key (Geocoding API enabled): https://console.cloud.google.com/google/maps-apis/credentials
- MapTiler API key: https://cloud.maptiler.com/account/keys/

Without keys, geocoding calls and map tiles will fail/return blank tiles, but the rest of the app is unaffected.

+ 26
- 0
app/.editorconfig Bestand weergeven

@@ -0,0 +1,26 @@
; This file is for unifying the coding style for different editors and IDEs.
; More information at https://editorconfig.org

root = true

[*]
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.bat]
end_of_line = crlf

[*.yml]
indent_size = 2

[*.twig]
insert_final_newline = false

[*.neon]
indent_style = tab

[Makefile]
indent_style = tab

+ 36
- 0
app/.gitattributes Bestand weergeven

@@ -0,0 +1,36 @@
# Define the line ending behavior of the different file extensions
# Set default behavior, in case users don't have core.autocrlf set.
* text text=auto eol=lf

# Declare files that will always have CRLF line endings on checkout.
*.bat eol=crlf

# Declare files that will always have LF line endings on checkout.
*.pem eol=lf

# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.avif binary
*.ico binary
*.mo binary
*.pdf binary
*.xls binary
*.xlsx binary
*.phar binary
*.woff binary
*.woff2 binary
*.ttc binary
*.ttf binary
*.otf binary
*.eot binary
*.gz binary
*.bz2 binary
*.7z binary
*.zip binary
*.webm binary
*.mp4 binary
*.ogv binary

+ 23
- 0
app/.github/ISSUE_TEMPLATE.md Bestand weergeven

@@ -0,0 +1,23 @@
This is a (multiple allowed):

* [x] bug
* [ ] enhancement
* [ ] feature-discussion (RFC)

* CakePHP Application Skeleton Version: EXACT RELEASE VERSION OR COMMIT HASH, HERE.
* Platform and Target: YOUR WEB-SERVER, DATABASE AND OTHER RELEVANT INFO AND HOW THE REQUEST IS BEING MADE, HERE.

### What you did
EXPLAIN WHAT YOU DID, PREFERABLY WITH CODE EXAMPLES, HERE.

### What happened
EXPLAIN WHAT IS ACTUALLY HAPPENING, HERE.

### What you expected to happen
EXPLAIN WHAT IS TO BE EXPECTED, HERE.

P.S. Remember, an issue is not the place to ask questions. You can use [Stack Overflow](https://stackoverflow.com/questions/tagged/cakephp)
for that or join the #cakephp channel on irc.freenode.net, where we will be more
than happy to help answer your questions.

Before you open an issue, please check if a similar issue already exists or has been closed before.

+ 14
- 0
app/.github/PULL_REQUEST_TEMPLATE.md Bestand weergeven

@@ -0,0 +1,14 @@
<!---

**PLEASE NOTE:**

This is only a issue tracker for issues related to the CakePHP Application Skeleton.
For CakePHP Framework issues please use this [issue tracker](https://github.com/cakephp/cakephp/issues).

Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue.

The best way to propose a feature is to open an issue first and discuss your ideas there before implementing them.

Always follow the [contribution guidelines](https://github.com/cakephp/cakephp/blob/master/.github/CONTRIBUTING.md) guidelines when submitting a pull request. In particular, make sure existing tests still pass, and add tests for all new behavior. When fixing a bug, you may want to add a test to verify the fix.

-->

+ 12
- 0
app/.github/dependabot.yml Bestand weergeven

@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: composer
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10

+ 81
- 0
app/.github/workflows/ci.yml Bestand weergeven

@@ -0,0 +1,81 @@
name: CI

on:
push:
branches:
- '5.x'
- '5.next'
- '6.x'
pull_request:
branches:
- '*'
workflow_dispatch:

permissions:
contents: read

jobs:
testsuite:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- php-version: '8.2'
dependencies: 'lowest'
- php-version: '8.5'
dependencies: 'highest'
- php-version: '8.5'
dependencies: 'highest'

steps:
- uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: mbstring, intl, pdo_sqlite
ini-values: zend.assertions=1
coverage: none

- name: Composer install
uses: ramsey/composer-install@v4
with:
dependency-versions: ${{ matrix.dependencies }}
composer-options: ${{ matrix.composer-options }}

- name: Composer post install
run: composer run-script post-install-cmd --no-interaction

- name: Run PHPUnit
run: vendor/bin/phpunit
env:
DATABASE_TEST_URL: sqlite://./testdb.sqlite

coding-standard:
name: Coding Standard & Static Analysis
runs-on: ubuntu-24.04

steps:
- uses: actions/checkout@v7

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, intl
coverage: none
tools: cs2pr, phpstan:2.1

- name: Composer install
uses: ramsey/composer-install@v4

- name: Run PHP CodeSniffer
run: vendor/bin/phpcs --report=checkstyle | cs2pr

- name: Run phpstan
if: always()
run: phpstan
env:
SECURITY_SALT: f76f1c8475585c46c6acd3ddcb8f5e0f15de524637bb4080a08c4afe7cfc9144

+ 29
- 0
app/.github/workflows/stale.yml Bestand weergeven

@@ -0,0 +1,29 @@
name: Mark stale issues and pull requests

on:
schedule:
- cron: "0 0 * * *"

permissions:
contents: read

jobs:
stale:

permissions:
issues: write # for actions/stale to close stale issues
pull-requests: write # for actions/stale to close stale PRs
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue is stale because it has been open for 120 days with no activity. Remove the `stale` label or comment or this will be closed in 15 days'
stale-pr-message: 'This pull request is stale because it has been open 30 days with no activity. Remove the `stale` label or comment on this issue, or it will be closed in 15 days'
stale-issue-label: 'stale'
stale-pr-label: 'stale'
days-before-stale: 120
days-before-close: 15
exempt-issue-labels: 'pinned'
exempt-pr-labels: 'pinned'

+ 52
- 0
app/.gitignore Bestand weergeven

@@ -0,0 +1,52 @@
# CakePHP specific files #
##########################
/config/app_local.php
/config/.env
/logs/*
/tmp/*
/vendor/*

# OS generated files #
######################
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
# Icon must end with two \r
Icon
ehthumbs.db
Thumbs.db
.directory

# Tool specific files #
#######################
# PHPUnit
.phpunit.cache
tests.sqlite
# vim
*~
*.swp
*.swo
# sublime text & textmate
*.sublime-*
*.stTheme.cache
*.tmlanguage.cache
*.tmPreferences.cache
# Eclipse
.settings/*
# JetBrains, aka PHPStorm, IntelliJ IDEA
.idea/*
# NetBeans
nbproject/*
# Visual Studio Code
.vscode
# nova
.nova
# Sass preprocessor
.sass-cache/
# node
/node_modules/*
# yarn
yarn-debug.log
yarn-error.log

+ 12
- 0
app/.htaccess Bestand weergeven

@@ -0,0 +1,12 @@
# Uncomment the following to prevent the httpoxy vulnerability
# See: https://httpoxy.org/
#<IfModule mod_headers.c>
# RequestHeader unset Proxy
#</IfModule>

<IfModule mod_rewrite.c>
RewriteEngine on
RewriteRule ^(\.well-known/.*)$ $1 [L]
RewriteRule ^$ webroot/ [L]
RewriteRule (.*) webroot/$1 [L]
</IfModule>

+ 58
- 0
app/README.md Bestand weergeven

@@ -0,0 +1,58 @@
# CakePHP Application Skeleton

![Build Status](https://github.com/cakephp/app/actions/workflows/ci.yml/badge.svg?branch=5.x)
[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/app.svg?style=flat-square)](https://packagist.org/packages/cakephp/app)
[![PHPStan](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg?style=flat-square)](https://github.com/phpstan/phpstan)

A skeleton for creating applications with [CakePHP](https://cakephp.org) 5.x.

The framework source code can be found here: [cakephp/cakephp](https://github.com/cakephp/cakephp).

## Installation

1. Download [Composer](https://getcomposer.org/doc/00-intro.md) or update `composer self-update`.
2. Run `php composer.phar create-project --prefer-dist cakephp/app [app_name]`.

If Composer is installed globally, run

```bash
composer create-project --prefer-dist cakephp/app
```

In case you want to use a custom app dir name (e.g. `/myapp/`):

```bash
composer create-project --prefer-dist cakephp/app myapp
```

You can now either use your machine's webserver to view the default home page, or start
up the built-in webserver with:

```bash
bin/cake server -p 8765
```

Then visit `http://localhost:8765` to see the welcome page.

## Demo app

Check out the [5.x-demo branch](https://github.com/cakephp/app/tree/5.x-demo), which contains demo migrations and a seeder.
See the [README](https://github.com/cakephp/app/blob/5.x-demo/README.md) on how to get it running.

## Update

Since this skeleton is a starting point for your application and various files
would have been modified as per your needs, there isn't a way to provide
automated upgrades, so you have to do any updates manually.

## Configuration

Read and edit the environment specific `config/app_local.php` and set up the
`'Datasources'` and any other configuration relevant for your application.
Other environment agnostic settings can be changed in `config/app.php`.

## Layout

The app skeleton uses [Milligram](https://milligram.io/) (v1.3) minimalist CSS
framework by default. You can, however, replace it with any other library or
custom styles.

+ 47
- 0
app/bin/bash_completion.sh Bestand weergeven

@@ -0,0 +1,47 @@
#
# Bash completion file for CakePHP console.
# Copy this file to a file named `cake` under `/etc/bash_completion.d/`.
# For more info check https://book.cakephp.org/5/en/console-commands/completion.html#how-to-enable-bash-autocompletion-for-the-cakephp-console
#

_cake()
{
local cur prev opts cake
COMPREPLY=()
cake="${COMP_WORDS[0]}"
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"

if [[ "$cur" == -* ]] ; then
if [[ ${COMP_CWORD} = 1 ]] ; then
opts=$(${cake} completion options)
elif [[ ${COMP_CWORD} = 2 ]] ; then
opts=$(${cake} completion options "${COMP_WORDS[1]}")
else
opts=$(${cake} completion options "${COMP_WORDS[1]}" "${COMP_WORDS[2]}")
fi

COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
fi

if [[ ${COMP_CWORD} = 1 ]] ; then
opts=$(${cake} completion commands)
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0
fi

if [[ ${COMP_CWORD} = 2 ]] ; then
opts=$(${cake} completion subcommands $prev)
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
if [[ $COMPREPLY = "" ]] ; then
_filedir
return 0
fi
return 0
fi

return 0
}

complete -F _cake cake bin/cake

+ 75
- 0
app/bin/cake Bestand weergeven

@@ -0,0 +1,75 @@
#!/usr/bin/env sh
################################################################################
#
# Cake is a shell script for invoking CakePHP shell commands
#
# CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
# Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
#
# Licensed under The MIT License
# For full copyright and license information, please see the LICENSE.txt
# Redistributions of files must retain the above copyright notice.
#
# @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
# @link https://cakephp.org CakePHP(tm) Project
# @since 1.2.0
# @license https://opensource.org/licenses/mit-license.php MIT License
#
################################################################################

# Canonicalize by following every symlink of the given name recursively
canonicalize() {
NAME="$1"
if [ -f "$NAME" ]
then
DIR=$(dirname -- "$NAME")
NAME=$(cd -P "$DIR" > /dev/null && pwd -P)/$(basename -- "$NAME")
fi
while [ -h "$NAME" ]; do
DIR=$(dirname -- "$NAME")
SYM=$(readlink "$NAME")
NAME=$(cd "$DIR" > /dev/null && cd "$(dirname -- "$SYM")" > /dev/null && pwd)/$(basename -- "$SYM")
done
echo "$NAME"
}

# Find a CLI version of PHP
findCliPhp() {
for TESTEXEC in php php-cli /usr/local/bin/php
do
SAPI=$(echo "<?= PHP_SAPI ?>" | $TESTEXEC 2>/dev/null)
if [ "$SAPI" = "cli" ]
then
echo $TESTEXEC
return
fi
done
echo "Failed to find a CLI version of PHP; falling back to system standard php executable" >&2
echo "php";
}

# If current path is a symlink, resolve to real path
realname="$0"
if [ -L "$realname" ]
then
realname=$(readlink -f "$0")
fi

CONSOLE=$(dirname -- "$(canonicalize "$realname")")
APP=$(dirname "$CONSOLE")

# If your CLI PHP is somewhere that this doesn't find, you can define a PHP environment
# variable with the correct path in it.
if [ -z "$PHP" ]
then
PHP=$(findCliPhp)
fi

if [ "$(basename "$realname")" != 'cake' ]
then
exec "$PHP" "$CONSOLE"/cake.php "$(basename "$realname")" "$@"
else
exec "$PHP" "$CONSOLE"/cake.php "$@"
fi

exit

+ 27
- 0
app/bin/cake.bat Bestand weergeven

@@ -0,0 +1,27 @@
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::
:: Cake is a Windows batch script for invoking CakePHP shell commands
::
:: CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
:: Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
::
:: Licensed under The MIT License
:: Redistributions of files must retain the above copyright notice.
::
:: @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
:: @link https://cakephp.org CakePHP(tm) Project
:: @since 2.0.0
:: @license https://opensource.org/licenses/mit-license.php MIT License
::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::

@echo off

SET app=%0
SET lib=%~dp0

php "%lib%cake.php" %*

echo.

exit /B %ERRORLEVEL%

+ 10
- 0
app/bin/cake.php Bestand weergeven

@@ -0,0 +1,10 @@
#!/usr/bin/php -q
<?php
require dirname(__DIR__) . '/vendor/autoload.php';

use App\Application;
use Cake\Console\CommandRunner;

// Build the runner with an application and root executable name.
$runner = new CommandRunner(new Application(dirname(__DIR__) . '/config'), 'cake');
exit($runner->run($argv));

+ 61
- 0
app/composer.json Bestand weergeven

@@ -0,0 +1,61 @@
{
"name": "cakephp/app",
"description": "CakePHP skeleton app",
"license": "MIT",
"type": "project",
"homepage": "https://cakephp.org",
"require": {
"php": ">=8.2",
"cakephp/cakephp": "5.4.*",
"cakephp/migrations": "^5.0",
"cakephp/plugin-installer": "^2.0",
"dereuromark/cakephp-geo": "^3.8",
"geocoder-php/google-maps-provider": "^4.8",
"geocoder-php/provider-implementation": "*",
"mobiledetect/mobiledetectlib": "^4.8.03",
"willdurand/geocoder": "^5.0"
},
"require-dev": {
"cakephp/bake": "^3.6",
"cakephp/cakephp-codesniffer": "^5.3",
"cakephp/debug_kit": "^5.2",
"josegonzalez/dotenv": "^4.0",
"phpunit/phpunit": "^11.5.3 || ^12.1.3 || ^13.0"
},
"suggest": {
"cakephp/repl": "Console tools for a REPL interface for CakePHP applications.",
"dereuromark/cakephp-ide-helper": "After baking your code, this keeps your annotations in sync with the code evolving from there on for maximum IDE and PHPStan/Psalm compatibility.",
"markstory/asset_compress": "An asset compression plugin which provides file concatenation and a flexible filter system for preprocessing and minification.",
"phpstan/phpstan": "PHPStan focuses on finding errors in your code without actually running it. It catches whole classes of bugs even before you write tests for the code."
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Test\\": "tests/",
"Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
}
},
"config": {
"allow-plugins": {
"cakephp/plugin-installer": true,
"dealerdirect/phpcodesniffer-composer-installer": true
},
"platform-check": true,
"sort-packages": true
},
"scripts": {
"post-install-cmd": "App\\Console\\Installer::postInstall",
"post-create-project-cmd": "App\\Console\\Installer::postInstall",
"check": [
"@test",
"@cs-check"
],
"cs-check": "phpcs --colors -p",
"cs-fix": "phpcbf --colors -p",
"test": "phpunit --colors=always"
}
}

+ 6719
- 0
app/composer.lock
Diff onderdrukt omdat het te groot bestand
Bestand weergeven


+ 41
- 0
app/config/.env.example Bestand weergeven

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Used as a default to seed config/.env which
# enables you to use environment variables to configure
# the aspects of your application that vary by
# environment.
#
# Having this file in production is considered a **SECURITY RISK** and also decreases
# the bootstrap performance of your application.
#
# To use this file, first copy it into `config/.env`. Also ensure the related
# code block for loading this file is uncommented in `config/bootstrap.php`
#
# In development .env files are parsed by PHP
# and set into the environment. This provides a simpler
# development workflow over standard environment variables.
export APP_NAME="__APP_NAME__"
export DEBUG="true"
export APP_ENCODING="UTF-8"
export APP_DEFAULT_LOCALE="en_US"
export APP_DEFAULT_TIMEZONE="UTC"
# SECURITY: Set this to your domain to prevent Host Header Injection attacks
# This is REQUIRED in production for password resets and other security features
export APP_FULL_BASE_URL="https://example.com"
export SECURITY_SALT="__SALT__"

# Uncomment these to define cache configuration via environment variables.
#export CACHE_DURATION="+2 minutes"
#export CACHE_DEFAULT_URL="file:///path/to/tmp/cache/?prefix=${APP_NAME}_default_&duration=${CACHE_DURATION}"
#export CACHE_CAKECORE_URL="file:///path/to/tmp/cache/persistent?prefix=${APP_NAME}_cake_translations_&serialize=true&duration=${CACHE_DURATION}"
#export CACHE_CAKEMODEL_URL="file:///path/to/tmp/cache/models?prefix=${APP_NAME}_cake_model_&serialize=true&duration=${CACHE_DURATION}"

# Uncomment these to define email transport configuration via environment variables.
#export EMAIL_TRANSPORT_DEFAULT_URL=""

# Uncomment these to define database configuration via environment variables.
#export DATABASE_URL="mysql://my_app:secret@localhost/${APP_NAME}?encoding=utf8&timezone=UTC&cacheMetadata=true&quoteIdentifiers=false&persistent=false"
#export DATABASE_TEST_URL="mysql://my_app:secret@localhost/test_${APP_NAME}?encoding=utf8&timezone=UTC&cacheMetadata=true&quoteIdentifiers=false&persistent=false"

# Uncomment these to define logging configuration via environment variables.
#export LOG_DEBUG_URL="file:///path/to/logs/?levels[]=notice&levels[]=info&levels[]=debug&file=debug"
#export LOG_ERROR_URL="file:///path/to/logs/?levels[]=warning&levels[]=error&levels[]=critical&levels[]=alert&levels[]=emergency&file=error"

+ 38
- 0
app/config/Migrations/20260727204806_CreateTerritories.php Bestand weergeven

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);

use Migrations\BaseMigration;

class CreateTerritories extends BaseMigration
{
/**
* Change Method.
*
* More information on this method is available here:
* https://book.cakephp.org/migrations/5/guides/writing-migrations/migration-methods.html#the-change-method
*
* @return void
*/
public function change(): void
{
$table = $this->table('territories');
$table->addColumn('name', 'string', [
'default' => null,
'limit' => 255,
'null' => false,
]);
$table->addColumn('polygon', 'json', [
'default' => null,
'null' => false,
]);
$table->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
]);
$table->addColumn('modified', 'datetime', [
'default' => null,
'null' => false,
]);
$table->create();
}
}

+ 70
- 0
app/config/Migrations/20260727210606_CreateAddresses.php Bestand weergeven

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);

use Migrations\BaseMigration;

class CreateAddresses extends BaseMigration
{
/**
* Change Method.
*
* More information on this method is available here:
* https://book.cakephp.org/migrations/5/guides/writing-migrations/migration-methods.html#the-change-method
*
* @return void
*/
public function change(): void
{
$table = $this->table('addresses');
$table->addColumn('territory_id', 'integer', [
'default' => null,
'limit' => 11,
'null' => false,
]);
$table->addColumn('osm_type', 'string', [
'default' => null,
'limit' => 255,
'null' => false,
]);
$table->addColumn('osm_id', 'biginteger', [
'default' => null,
'limit' => 20,
'null' => false,
]);
$table->addColumn('house_number', 'string', [
'default' => null,
'limit' => 255,
'null' => true,
]);
$table->addColumn('street', 'string', [
'default' => null,
'limit' => 255,
'null' => true,
]);
$table->addColumn('full_address', 'string', [
'default' => null,
'limit' => 255,
'null' => false,
]);
$table->addColumn('lat', 'decimal', [
'default' => null,
'null' => false,
'precision' => 10,
'scale' => 7,
]);
$table->addColumn('lng', 'decimal', [
'default' => null,
'null' => false,
'precision' => 10,
'scale' => 7,
]);
$table->addColumn('created', 'datetime', [
'default' => null,
'null' => false,
]);
$table->addIndex(['territory_id']);
$table->addIndex(['territory_id', 'osm_type', 'osm_id'], ['unique' => true, 'name' => 'idx_territory_osm_unique']);
$table->addForeignKey('territory_id', 'territories', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']);
$table->create();
}
}

BIN
app/config/Migrations/schema-dump-default.lock Bestand weergeven


+ 513
- 0
app/config/app.php Bestand weergeven

@@ -0,0 +1,513 @@
<?php

use Cake\Cache\Engine\FileEngine;
use Cake\Database\Connection;
use Cake\Database\Driver\Mysql;
use Cake\Log\Engine\FileLog;
use Cake\Mailer\Transport\MailTransport;
use Geo\Geocoder\Geocoder;
use function Cake\Core\env;

return [
/*
* Debug Level:
*
* Production Mode:
* false: No error messages, errors, or warnings shown.
*
* Development Mode:
* true: Errors and warnings shown.
*/
'debug' => filter_var(env('DEBUG', false), FILTER_VALIDATE_BOOLEAN),

/*
* Configure basic information about the application.
*
* - namespace - The namespace to find app classes under.
* - defaultLocale - The default locale for translation, formatting currencies and numbers, date and time.
* - encoding - The encoding used for HTML + database connections.
* - base - The base directory the app resides in. If false this
* will be auto-detected.
* - dir - Name of app directory.
* - webroot - The webroot directory.
* - wwwRoot - The file path to webroot.
* - baseUrl - To configure CakePHP to *not* use mod_rewrite and to
* use CakePHP pretty URLs, remove these .htaccess
* files:
* /.htaccess
* /webroot/.htaccess
* And uncomment the baseUrl key below.
* - fullBaseUrl - SECURITY: A base URL to use for absolute links.
* IMPORTANT: This MUST be set in production to prevent Host Header Injection attacks
* that can compromise password reset and other security-critical features.
* Set this via APP_FULL_BASE_URL environment variable or directly in config.
* Example: 'https://example.com'
* When not set, the application will throw an exception in production mode.
* - imageBaseUrl - Web path to the public images/ directory under webroot.
* - cssBaseUrl - Web path to the public css/ directory under webroot.
* - jsBaseUrl - Web path to the public js/ directory under webroot.
* - paths - Configure paths for non class-based resources. Supports the
* `plugins`, `templates`, `locales` subkeys, which allow the definition of
* paths for plugins, view templates and locale files respectively.
*/
'App' => [
'namespace' => 'App',
'encoding' => env('APP_ENCODING', 'UTF-8'),
'defaultLocale' => env('APP_DEFAULT_LOCALE', 'en_US'),
'defaultTimezone' => env('APP_DEFAULT_TIMEZONE', 'UTC'),
'base' => false,
'dir' => 'src',
'webroot' => 'webroot',
'wwwRoot' => WWW_ROOT,
//'baseUrl' => env('SCRIPT_NAME'),
'fullBaseUrl' => env('APP_FULL_BASE_URL', false),
'imageBaseUrl' => 'img/',
'cssBaseUrl' => 'css/',
'jsBaseUrl' => 'js/',
'paths' => [
'plugins' => [ROOT . DS . 'plugins' . DS],
'templates' => [ROOT . DS . 'templates' . DS],
'locales' => [RESOURCES . 'locales' . DS],
],
],

/*
* Security and encryption configuration
*
* - salt - A random string used in security hashing methods.
* The salt value is also used as the encryption key.
* You should treat it as extremely sensitive data.
*/
'Security' => [
'salt' => env('SECURITY_SALT'),
],

/*
* Apply timestamps with the last modified time to static assets (js, css, images).
* Will append a querystring parameter containing the time the file was modified.
* This is useful for busting browser caches.
*
* Set to true to apply timestamps when debug is true. Set to 'force' to always
* enable timestamping regardless of debug value.
*/
'Asset' => [
//'timestamp' => true,
// 'cacheTime' => '+1 year'
],

/*
* Configure the cache adapters.
*/
'Cache' => [
'default' => [
'className' => FileEngine::class,
'path' => CACHE,
'url' => env('CACHE_DEFAULT_URL', null),
],

/*
* Configure the cache used for general framework caching.
* Translation cache files are stored with this configuration.
* Duration will be set to '+2 minutes' in bootstrap.php when debug = true
* If you set 'className' => 'Null' core cache will be disabled.
*/
'_cake_translations_' => [
'className' => FileEngine::class,
'prefix' => 'myapp_cake_translations_',
'path' => CACHE . 'persistent' . DS,
'serialize' => true,
'duration' => '+1 years',
'url' => env('CACHE_CAKECORE_URL', null),
],

/*
* Configure the cache for model and datasource caches. This cache
* configuration is used to store schema descriptions, and table listings
* in connections.
* Duration will be set to '+2 minutes' in bootstrap.php when debug = true
*/
'_cake_model_' => [
'className' => FileEngine::class,
'prefix' => 'myapp_cake_model_',
'path' => CACHE . 'models' . DS,
'serialize' => true,
'duration' => '+1 years',
'url' => env('CACHE_CAKEMODEL_URL', null),
],
],

/*
* Configure the Error and Exception handlers used by your application.
*
* By default errors are displayed using Debugger, when debug is true and logged
* by Cake\Log\Log when debug is false.
*
* In CLI environments exceptions will be printed to stderr with a backtrace.
* In web environments an HTML page will be displayed for the exception.
* With debug true, framework errors like Missing Controller will be displayed.
* When debug is false, framework errors will be coerced into generic HTTP errors.
*
* Options:
*
* - `errorLevel` - int - The level of errors you are interested in capturing.
* - `trace` - boolean - Whether backtraces should be included in
* logged errors/exceptions.
* - `log` - boolean - Whether you want exceptions logged.
* - `exceptionRenderer` - string - The class responsible for rendering uncaught exceptions.
* The chosen class will be used for both CLI and web environments. If you want different
* classes used in CLI and web environments you'll need to write that conditional logic as well.
* The conventional location for custom renderers is in `src/Error`. Your exception renderer needs to
* implement the `render()` method and return either a string or Http\Response.
* `errorRenderer` - string - The class responsible for rendering PHP errors. The selected
* class will be used for both web and CLI contexts. If you want different classes for each environment
* you'll need to write that conditional logic as well. Error renderers need to
* to implement the `Cake\Error\ErrorRendererInterface`.
* - `skipLog` - array - List of exceptions to skip for logging. Exceptions that
* extend one of the listed exceptions will also be skipped for logging.
* E.g.:
* `'skipLog' => ['Cake\Http\Exception\NotFoundException', 'Cake\Http\Exception\UnauthorizedException']`
* - `extraFatalErrorMemory` - int - The number of megabytes to increase the memory limit by
* when a fatal error is encountered. This allows
* breathing room to complete logging or error handling.
* - `ignoredDeprecationPaths` - array - A list of glob-compatible file paths that deprecations
* should be ignored in. Use this to ignore deprecations for plugins or parts of
* your application that still emits deprecations.
* - `traceFormat` - when logging errors, List of `'array'`, `'points'`, `'shortPoints'`, defaults to `shortPoints`.
*/
'Error' => [
'errorLevel' => E_ALL,
'skipLog' => [],
'log' => true,
'trace' => true,
'ignoredDeprecationPaths' => [],
'traceFormat' => null,
],

/*
* Debugger configuration
*
* Define development error values for Cake\Error\Debugger
*
* - `editor` Set the editor URL format you want to use.
* By default atom, emacs, macvim, phpstorm, sublime, textmate, and vscode are
* available. You can add additional editor link formats using
* `Debugger::addEditor()` during your application bootstrap.
* - `editorBasePath` - The base path to your project for editor integration.
* Used to generate file links in stack traces.
* - `outputMask` A mapping of `key` to `replacement` values that
* `Debugger` should replace in dumped data and logs generated by `Debugger`.
*/
'Debugger' => [
'editor' => 'phpstorm',
],

/*
* Email configuration.
*
* By defining transports separately from delivery profiles you can easily
* re-use transport configuration across multiple profiles.
*
* You can specify multiple configurations for production, development and
* testing.
*
* Each transport needs a `className`. Valid options are as follows:
*
* Mail - Send using PHP mail function
* Smtp - Send using SMTP
* Debug - Do not send the email, just return the result
*
* You can add custom transports (or override existing transports) by adding the
* appropriate file to src/Mailer/Transport. Transports should be named
* 'YourTransport.php', where 'Your' is the name of the transport.
*/
'EmailTransport' => [
'default' => [
'className' => MailTransport::class,
/*
* The keys host, port, timeout, username, password, client and tls
* are used in SMTP transports
*/
'host' => 'localhost',
'port' => 25,
'timeout' => 30,
/*
* It is recommended to set these options through your environment or app_local.php
*/
//'username' => null,
//'password' => null,
'client' => null,
'tls' => false,
'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null),
],
],

/*
* Email delivery profiles
*
* Delivery profiles allow you to predefine various properties about email
* messages from your application and give the settings a name. This saves
* duplication across your application and makes maintenance and development
* easier. Each profile accepts a number of keys. See `Cake\Mailer\Mailer`
* for more information.
*/
'Email' => [
'default' => [
'transport' => 'default',
'from' => 'you@localhost',
/*
* Will by default be set to config value of App.encoding, if that exists otherwise to UTF-8.
*/
//'charset' => 'utf-8',
//'headerCharset' => 'utf-8',
],
],

/*
* Connection information used by the ORM to connect
* to your application's datastores.
*
* ### Notes
* - Drivers include Mysql Postgres Sqlite Sqlserver
* See vendor\cakephp\cakephp\src\Database\Driver for the complete list
* - Do not use periods in database name - it may lead to errors.
* See https://github.com/cakephp/cakephp/issues/6471 for details.
* - 'encoding' is recommended to be set to full UTF-8 4-Byte support.
* E.g set it to 'utf8mb4' in MariaDB and MySQL and 'utf8' for any
* other RDBMS.
*/
'Datasources' => [
/*
* These configurations should contain permanent settings used
* by all environments.
*
* The values in app_local.php will override any values set here
* and should be used for local and per-environment configurations.
*
* Environment variable-based configurations can be loaded here or
* in app_local.php depending on the application's needs.
*/
'default' => [
'className' => Connection::class,
'driver' => Mysql::class,
'persistent' => false,
'timezone' => 'UTC',

/*
* For MariaDB/MySQL the internal default changed from utf8 to utf8mb4, aka full utf-8 support
*/
'encoding' => 'utf8mb4',

/*
* If your MySQL server is configured with `skip-character-set-client-handshake`
* then you MUST use the `flags` config to set your charset encoding.
* For e.g. `'flags' => [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4']`
*/
'flags' => [],
'cacheMetadata' => true,
'log' => false,

/*
* Set identifier quoting to true if you are using reserved words or
* special characters in your table or column names. Enabling this
* setting will result in queries built using the Query Builder having
* identifiers quoted when creating SQL. It should be noted that this
* decreases performance because each query needs to be traversed and
* manipulated before being executed.
*/
'quoteIdentifiers' => false,

/*
* During development, if using MySQL < 5.6, uncommenting the
* following line could boost the speed at which schema metadata is
* fetched from the database. It can also be set directly with the
* mysql configuration directive 'innodb_stats_on_metadata = 0'
* which is the recommended value in production environments
*/
//'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'],
],

/*
* The test connection is used during the test suite.
*/
'test' => [
'className' => Connection::class,
'driver' => Mysql::class,
'persistent' => false,
'timezone' => 'UTC',
'encoding' => 'utf8mb4',
'flags' => [],
'cacheMetadata' => true,
'quoteIdentifiers' => false,
'log' => false,
//'init' => ['SET GLOBAL innodb_stats_on_metadata = 0'],
],
],

/*
* Configures logging options
*/
'Log' => [
'debug' => [
'className' => FileLog::class,
'path' => LOGS,
'file' => 'debug',
'url' => env('LOG_DEBUG_URL', null),
'scopes' => null,
'levels' => ['notice', 'info', 'debug'],
],
'error' => [
'className' => FileLog::class,
'path' => LOGS,
'file' => 'error',
'url' => env('LOG_ERROR_URL', null),
'scopes' => null,
'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'],
],
// To enable this dedicated query log, you need to set your datasource's log flag to true
'queries' => [
'className' => FileLog::class,
'path' => LOGS,
'file' => 'queries',
'url' => env('LOG_QUERIES_URL', null),
'scopes' => ['cake.database.queries'],
],
],

/*
* Session configuration.
*
* Contains an array of settings to use for session configuration. The
* `defaults` key is used to define a default preset to use for sessions, any
* settings declared here will override the settings of the default config.
*
* ## Options
*
* - `cookie` - The name of the cookie to use. Defaults to value set for `session.name` php.ini config.
* Avoid using `.` in cookie names, as PHP will drop sessions from cookies with `.` in the name.
* - `cookiePath` - The url path for which session cookie is set. Maps to the
* `session.cookie_path` php.ini config. Defaults to base path of app.
* - `timeout` - The time in minutes a session can be 'idle'. If no request is received in
* this duration, the session will be expired and rotated. Pass 0 to disable idle timeout checks.
* - `defaults` - The default configuration set to use as a basis for your session.
* There are four built-in options: php, cake, cache, database.
* - `handler` - Can be used to enable a custom session handler. Expects an
* array with at least the `engine` key, being the name of the Session engine
* class to use for managing the session. CakePHP bundles the `CacheSession`
* and `DatabaseSession` engines.
* - `ini` - An associative array of additional 'session.*` ini values to set.
*
* Within the `ini` key, you will likely want to define:
*
* - `session.cookie_lifetime` - The number of seconds that cookies are valid for. This
* should be longer than `Session.timeout`.
* - `session.gc_maxlifetime` - The number of seconds after which a session is considered 'garbage'
* that can be deleted by PHP's session cleanup behavior. This value should be greater than both
* `Sesssion.timeout` and `session.cookie_lifetime`.
*
* The built-in `defaults` options are:
*
* - 'php' - Uses settings defined in your php.ini.
* - 'cake' - Saves session files in CakePHP's /tmp directory.
* - 'database' - Uses CakePHP's database sessions.
* - 'cache' - Use the Cache class to save sessions.
*
* To define a custom session handler, save it at src/Http/Session/<name>.php.
* Make sure the class implements PHP's `SessionHandlerInterface` and set
* Session.handler to <name>
*
* To use database sessions, load the SQL file located at config/schema/sessions.sql
*/
'Session' => [
'defaults' => 'php',
],

/**
* DebugKit configuration.
*
* Contains an array of configurations to apply to the DebugKit plugin, if loaded.
* Documentation: https://book.cakephp.org/debugkit/5/en/index.html#configuration
*
* ## Options
*
* - `panels` - Enable or disable panels. The key is the panel name, and the value is true to enable,
* or false to disable.
* - `includeSchemaReflection` - Set to true to enable logging of schema reflection queries. Disabled by default.
* - `safeTld` - Set an array of whitelisted TLDs for local development.
* - `forceEnable` - Force DebugKit to display. Careful with this, it is usually safer to simply whitelist
* your local TLDs.
* - `ignorePathsPattern` - Regex pattern (including delimiter) to ignore paths.
* DebugKit won’t save data for request URLs that match this regex.
* - `ignoreAuthorization` - Set to true to ignore Cake Authorization plugin for DebugKit requests.
* Disabled by default.
* - `maxDepth` - Defines how many levels of nested data should be shown in general for debug output.
* Default is 5. WARNING: Increasing the max depth level can lead to an out of memory error.
* - `variablesPanelMaxDepth` - Defines how many levels of nested data should be shown in the variables tab.
* Default is 5. WARNING: Increasing the max depth level can lead to an out of memory error.
*/
'DebugKit' => [
'forceEnable' => filter_var(env('DEBUG_KIT_FORCE_ENABLE', false), FILTER_VALIDATE_BOOLEAN),
'safeTld' => env('DEBUG_KIT_SAFE_TLD', null),
'ignoreAuthorization' => env('DEBUG_KIT_IGNORE_AUTHORIZATION', false),
],

/**
* TestSuite configuration.
*
* ## Options
*
* - `errorLevel` - Defaults to `E_ALL`. Can be set to `false` to disable overwrite error level.
* - `fixtureStrategy` - Defaults to TruncateStrategy. Can be set to any class implementing FixtureStrategyInterface.
*/
'TestSuite' => [
'errorLevel' => null,
'fixtureStrategy' => null,
],

/*
* Sub-applications listed in the site header's app switcher.
* Each entry needs a `label` and a `url` (anything Html->link()'s `url` accepts).
*/
'Apps' => [
['label' => 'Home', 'url' => '/'],
],

/*
* CakePHP Geo plugin: geocoder configuration.
* Address <-> lat/lng geocoding goes through Google Maps.
* Requires a Google Cloud API key with the Geocoding API enabled,
* set via the GOOGLE_MAPS_API_KEY environment variable.
*/
'Geocoder' => [
'provider' => Geocoder::PROVIDER_GOOGLE,
'google' => [
'apiKey' => env('GOOGLE_MAPS_API_KEY', ''),
'locale' => 'en',
],
],

/*
* LeafletHelper configuration: map tiles are served by MapTiler.
* Requires an API key, set via the MAPTILER_API_KEY environment variable.
* See https://cloud.maptiler.com/maps/ for available style/map IDs.
*/
'Leaflet' => [
// Default center: Grand Rapids, MI
'lat' => 42.9634,
'lng' => -85.6681,
'zoom' => 12,
'map' => [
'defaultLat' => 42.9634,
'defaultLng' => -85.6681,
'defaultZoom' => 12,
],
'tileLayer' => [
'url' => 'https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key=' . env('MAPTILER_API_KEY', ''),
'options' => [
'attribution' => '<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a> '
. '<a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; OpenStreetMap contributors</a>',
'maxZoom' => 20,
],
],
'autoScript' => true,
],
];

+ 97
- 0
app/config/app_local.example.php Bestand weergeven

@@ -0,0 +1,97 @@
<?php

use function Cake\Core\env;

/*
* Local configuration file to provide any overrides to your app.php configuration.
* Copy and save this file as app_local.php and make changes as required.
* Note: It is not recommended to commit files with credentials such as app_local.php
* into source code version control.
*/
return [
/*
* Debug Level:
*
* Production Mode:
* false: No error messages, errors, or warnings shown.
*
* Development Mode:
* true: Errors and warnings shown.
*/
'debug' => filter_var(env('DEBUG', true), FILTER_VALIDATE_BOOLEAN),

/*
* Security and encryption configuration
*
* - salt - A random string used in security hashing methods.
* The salt value is also used as the encryption key.
* You should treat it as extremely sensitive data.
*/
'Security' => [
'salt' => env('SECURITY_SALT', '__SALT__'),
],

/*
* Connection information used by the ORM to connect
* to your application's datastores.
*
* See app.php for more configuration options.
*/
'Datasources' => [
'default' => [
'host' => 'localhost',
/*
* CakePHP will use the default DB port based on the driver selected
* MySQL on MAMP uses port 8889, MAMP users will want to uncomment
* the following line and set the port accordingly
*/
//'port' => 'non_standard_port_number',

'username' => 'my_app',
'password' => 'secret',

'database' => 'my_app',
/*
* If not using the default 'public' schema with the PostgreSQL driver
* set it here.
*/
//'schema' => 'myapp',

/*
* You can use a DSN string to set the entire configuration
*/
'url' => env('DATABASE_URL', null),
],

/*
* The test connection is used during the test suite.
*/
'test' => [
'host' => 'localhost',
//'port' => 'non_standard_port_number',
'username' => 'my_app',
'password' => 'secret',
'database' => 'test_myapp',
//'schema' => 'myapp',
'url' => env('DATABASE_TEST_URL', 'sqlite://127.0.0.1/tmp/tests.sqlite'),
],
],

/*
* Email configuration.
*
* Host and credential configuration in case you are using SmtpTransport
*
* See app.php for more configuration options.
*/
'EmailTransport' => [
'default' => [
'host' => 'localhost',
'port' => 25,
'username' => null,
'password' => null,
'client' => null,
'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null),
],
],
];

+ 237
- 0
app/config/bootstrap.php Bestand weergeven

@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.8
* @license https://opensource.org/licenses/mit-license.php MIT License
*/

/*
* This file is loaded by your src/Application.php bootstrap method.
* Feel free to extend/extract parts of the bootstrap process into your own files
* to suit your needs/preferences.
*/

/*
* Configure paths required to find CakePHP + general filepath constants
*/
require __DIR__ . DIRECTORY_SEPARATOR . 'paths.php';

/*
* Bootstrap CakePHP
* Currently all this does is initialize the router (without loading your routes)
*/
require CORE_PATH . 'config' . DS . 'bootstrap.php';

use Cake\Cache\Cache;
use Cake\Core\Configure;
use Cake\Core\Configure\Engine\PhpConfig;
use Cake\Datasource\ConnectionManager;
use Cake\Error\ErrorTrap;
use Cake\Error\ExceptionTrap;
use Cake\Http\ServerRequest;
use Cake\Log\Log;
use Cake\Mailer\Mailer;
use Cake\Mailer\TransportFactory;
use Cake\Routing\Router;
use Cake\Utility\Security;
use Detection\MobileDetect;
use function Cake\Core\env;

/*
* Load global functions for collections, translations, debugging etc.
*/
require CAKE . 'functions.php';

/*
* See https://github.com/josegonzalez/php-dotenv for API details.
*
* Uncomment block of code below if you want to use `.env` file during development.
* You should copy `config/.env.example` to `config/.env` and set/modify the
* variables as required.
*
* The purpose of the .env file is to emulate the presence of the environment
* variables like they would be present in production.
*
* If you use .env files, be careful to not commit them to source control to avoid
* security risks. See https://github.com/josegonzalez/php-dotenv#general-security-information
* for more information for recommended practices.
*/
// if (!env('APP_NAME') && file_exists(CONFIG . '.env')) {
// $dotenv = new \josegonzalez\Dotenv\Loader([CONFIG . '.env']);
// $dotenv->parse()
// ->putenv()
// ->toEnv()
// ->toServer();
// }

/*
* Initializes default Config store and loads the main configuration file (app.php)
*
* CakePHP contains 2 configuration files after project creation:
* - `config/app.php` for the default application configuration.
* - `config/app_local.php` for environment specific configuration.
*/
try {
Configure::config('default', new PhpConfig());
Configure::load('app', 'default', false);
} catch (Exception $e) {
exit($e->getMessage() . "\n");
}

/*
* Load an environment local configuration file to provide overrides to your configuration.
* Notice: For security reasons app_local.php **should not** be included in your git repo.
*/
if (file_exists(CONFIG . 'app_local.php')) {
Configure::load('app_local', 'default');
}

/*
* When debug = true the metadata cache should only last for a short time.
*/
if (Configure::read('debug')) {
Configure::write('Cache._cake_model_.duration', '+1 minute');
Configure::write('Cache._cake_translations_.duration', '+1 minute');
}

/*
* Set the default server timezone. Using UTC makes time calculations / conversions easier.
* Check https://php.net/manual/en/timezones.php for list of valid timezone strings.
*/
date_default_timezone_set(Configure::read('App.defaultTimezone'));

/*
* Configure the mbstring extension to use the correct encoding.
*/
mb_internal_encoding(Configure::read('App.encoding'));

/*
* Set the default locale. This controls how dates, number and currency is
* formatted and sets the default language to use for translations.
*/
ini_set('intl.default_locale', Configure::read('App.defaultLocale'));

/*
* Register application error and exception handlers.
*/
(new ErrorTrap(Configure::read('Error')))->register();
(new ExceptionTrap(Configure::read('Error')))->register();

/*
* CLI/Command specific configuration.
*/
if (PHP_SAPI === 'cli') {
// Set the fullBaseUrl to allow URLs to be generated in commands.
// This is useful when sending email from commands.
// Configure::write('App.fullBaseUrl', php_uname('n'));

// Set logs to different files so they don't have permission conflicts.
if (Configure::check('Log.debug')) {
Configure::write('Log.debug.file', 'cli-debug');
}
if (Configure::check('Log.error')) {
Configure::write('Log.error.file', 'cli-error');
}
}

/*
* Set the full base URL for the application.
*
* SECURITY: In production, App.fullBaseUrl MUST be explicitly configured to prevent
* Host Header Injection attacks. The HostHeaderMiddleware enforces this requirement
* and validates incoming Host headers against the configured value.
*
* Set APP_FULL_BASE_URL in your environment variables or configure App.fullBaseUrl
* in config/app.php or config/app_local.php
*
* Example: APP_FULL_BASE_URL=https://example.com
*/
$fullBaseUrl = Configure::read('App.fullBaseUrl');
if (!$fullBaseUrl) {
$httpHost = env('HTTP_HOST');

/*
* Development mode fallback: Use HTTP_HOST for convenience.
* WARNING: This is ONLY safe in development. In production, the
* HostHeaderMiddleware will reject requests when fullBaseUrl is not configured.
*/
if ($httpHost) {
$s = null;
if (env('HTTPS') || env('HTTP_X_FORWARDED_PROTO') === 'https') {
$s = 's';
}
$fullBaseUrl = 'http' . $s . '://' . $httpHost;
}
unset($httpHost, $s);
}
if ($fullBaseUrl) {
Router::fullBaseUrl($fullBaseUrl);
}
unset($fullBaseUrl);

/*
* Apply the loaded configuration settings to their respective systems.
* This will also remove the loaded config data from memory.
*/
Cache::setConfig(Configure::consume('Cache'));
ConnectionManager::setConfig(Configure::consume('Datasources'));
TransportFactory::setConfig(Configure::consume('EmailTransport'));
Mailer::setConfig(Configure::consume('Email'));
Log::setConfig(Configure::consume('Log'));
Security::setSalt(Configure::consume('Security.salt'));

/*
* Setup detectors for mobile and tablet.
* If you don't use these checks you can safely remove this code
* and the mobiledetect package from composer.json.
*/
ServerRequest::addDetector('mobile', function ($request) {
$detector = new MobileDetect();

return $detector->isMobile();
});
ServerRequest::addDetector('tablet', function ($request) {
$detector = new MobileDetect();

return $detector->isTablet();
});

/*
* You can enable default locale format parsing by adding calls
* to `useLocaleParser()`. This enables the automatic conversion of
* locale specific date formats when processing request data. For details see
* @link https://book.cakephp.org/5/en/core-libraries/internationalization-and-localization.html#parsing-localized-datetime-data
*/
// \Cake\Database\TypeFactory::build('time')->useLocaleParser();
// \Cake\Database\TypeFactory::build('date')->useLocaleParser();
// \Cake\Database\TypeFactory::build('datetime')->useLocaleParser();
// \Cake\Database\TypeFactory::build('timestamp')->useLocaleParser();
// \Cake\Database\TypeFactory::build('datetimefractional')->useLocaleParser();
// \Cake\Database\TypeFactory::build('timestampfractional')->useLocaleParser();
// \Cake\Database\TypeFactory::build('datetimetimezone')->useLocaleParser();
// \Cake\Database\TypeFactory::build('timestamptimezone')->useLocaleParser();

/*
* Custom Inflector rules, can be set to correctly pluralize or singularize
* table, model, controller names or whatever other string is passed to the
* inflection functions.
*/
// \Cake\Utility\Inflector::rules('plural', ['/^(inflect)or$/i' => '\1ables']);
// \Cake\Utility\Inflector::rules('irregular', ['red' => 'redlings']);
// \Cake\Utility\Inflector::rules('uninflected', ['dontinflectme']);

// set a custom date and time format
// see https://book.cakephp.org/5/en/core-libraries/time.html#setting-the-default-locale-and-format-string
// and https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax
// \Cake\I18n\Date::setToStringFormat('dd.MM.yyyy');
// \Cake\I18n\Time::setToStringFormat('dd.MM.yyyy HH:mm');

+ 94
- 0
app/config/paths.php Bestand weergeven

@@ -0,0 +1,94 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license MIT License (https://opensource.org/licenses/mit-license.php)
*/

/*
* Use the DS to separate the directories in other defines
*/
if (!defined('DS')) {
define('DS', DIRECTORY_SEPARATOR);
}

/*
* These definitions should only be edited if you have cake installed in
* a directory layout other than the way it is distributed.
* When using custom settings be sure to use the DS and do not add a trailing DS.
*/

/*
* The full path to the directory which holds "src", WITHOUT a trailing DS.
*/
define('ROOT', dirname(__DIR__));

/*
* The actual directory name for the application directory. Normally
* named 'src'.
*/
define('APP_DIR', 'src');

/*
* Path to the application's directory.
*/
define('APP', ROOT . DS . APP_DIR . DS);

/*
* Path to the config directory.
*/
define('CONFIG', ROOT . DS . 'config' . DS);

/*
* File path to the webroot directory.
*
* To derive your webroot from your webserver change this to:
*
* `define('WWW_ROOT', rtrim($_SERVER['DOCUMENT_ROOT'], DS) . DS);`
*/
define('WWW_ROOT', ROOT . DS . 'webroot' . DS);

/*
* Path to the tests directory.
*/
define('TESTS', ROOT . DS . 'tests' . DS);

/*
* Path to the temporary files directory.
*/
define('TMP', ROOT . DS . 'tmp' . DS);

/*
* Path to the logs directory.
*/
define('LOGS', ROOT . DS . 'logs' . DS);

/*
* Path to the cache files directory. It can be shared between hosts in a multi-server setup.
*/
define('CACHE', TMP . 'cache' . DS);

/*
* Path to the resources directory.
*/
define('RESOURCES', ROOT . DS . 'resources' . DS);

/*
* The absolute path to the "cake" directory, WITHOUT a trailing DS.
*
* CakePHP should always be installed with composer, so look there.
*/
define('CAKE_CORE_INCLUDE_PATH', ROOT . DS . 'vendor' . DS . 'cakephp' . DS . 'cakephp');

/*
* Path to the cake directory.
*/
define('CORE_PATH', CAKE_CORE_INCLUDE_PATH . DS);
define('CAKE', CORE_PATH . 'src' . DS);

+ 36
- 0
app/config/plugins.php Bestand weergeven

@@ -0,0 +1,36 @@
<?php
/**
* Plugin configuration.
*
* In this file, you configure which plugins are loaded in the different states your app can be.
* It's loaded via the `parent::bootstrap();` call inside your `Application::bootstrap()` method.
* For more information see https://book.cakephp.org/5/en/plugins.html#loading-plugins-via-configuration-array
*
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 5.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/

/*
* List of plugins to load in the form `PluginName` => `[configuration options]`.
*
* Available options:
* - onlyDebug: Load the plugin only in debug mode. Default false.
* - onlyCli: Load the plugin only in CLI mode. Default false.
* - optional: Do not throw an exception if the plugin is not found. Default false.
*/
return [
'DebugKit' => ['onlyDebug' => true],
'Bake' => ['onlyCli' => true, 'optional' => true],
'Migrations' => ['onlyCli' => true],

// Additional plugins here
];

+ 96
- 0
app/config/routes.php Bestand weergeven

@@ -0,0 +1,96 @@
<?php
/**
* Routes configuration.
*
* In this file, you set up routes to your controllers and their actions.
* Routes are very important mechanism that allows you to freely connect
* different URLs to chosen controllers and their actions (functions).
*
* It's loaded within the context of `Application::routes()` method which
* receives a `RouteBuilder` instance `$routes` as method argument.
*
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @license https://opensource.org/licenses/mit-license.php MIT License
*/

use Cake\Routing\Route\DashedRoute;
use Cake\Routing\RouteBuilder;

/*
* This file is loaded in the context of the `Application` class.
* So you can use `$this` to reference the application class instance
* if required.
*/
return function (RouteBuilder $routes): void {
/*
* The default class to use for all routes
*
* The following route classes are supplied with CakePHP and are appropriate
* to set as the default:
*
* - Route
* - InflectedRoute
* - DashedRoute
*
* If no call is made to `Router::defaultRouteClass()`, the class used is
* `Route` (`Cake\Routing\Route\Route`)
*
* Note that `Route` does not do any inflections on URLs which will result in
* inconsistently cased URLs when used with `{plugin}`, `{controller}` and
* `{action}` markers.
*/
$routes->setRouteClass(DashedRoute::class);

$routes->scope('/', function (RouteBuilder $builder): void {
/*
* Here, we are connecting '/' (base path) to a controller called 'Pages',
* its action called 'display', and we pass a param to select the view file
* to use (in this case, templates/Pages/home.php)...
*/
$builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);

/*
* ...and connect the rest of 'Pages' controller's URLs.
*/
$builder->connect('/pages/*', 'Pages::display');

/*
* Connect catchall routes for all controllers.
*
* The `fallbacks` method is a shortcut for
*
* ```
* $builder->connect('/{controller}', ['action' => 'index']);
* $builder->connect('/{controller}/{action}/*', []);
* ```
*
* It is NOT recommended to use fallback routes after your initial prototyping phase!
* See https://book.cakephp.org/5/en/development/routing.html#fallbacks-method for more information
*/
$builder->fallbacks();
});

/*
* If you need a different set of middleware or none at all,
* open new scope and define routes there.
*
* ```
* $routes->scope('/api', function (RouteBuilder $builder): void {
* // No $builder->applyMiddleware() here.
*
* // Parse specified extensions from URLs
* // $builder->setExtensions(['json', 'xml']);
*
* // Connect API actions here.
* });
* ```
*/
};

+ 18
- 0
app/config/schema/i18n.sql Bestand weergeven

@@ -0,0 +1,18 @@
# Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
#
# Licensed under The MIT License
# For full copyright and license information, please see the LICENSE.txt
# Redistributions of files must retain the above copyright notice.
# MIT License (https://opensource.org/licenses/mit-license.php)

CREATE TABLE i18n (
id int NOT NULL auto_increment,
locale varchar(6) NOT NULL,
model varchar(255) NOT NULL,
foreign_key int(10) NOT NULL,
field varchar(255) NOT NULL,
content text,
PRIMARY KEY (id),
UNIQUE INDEX I18N_LOCALE_FIELD(locale, model, foreign_key, field),
INDEX I18N_FIELD(model, foreign_key, field)
);

+ 15
- 0
app/config/schema/sessions.sql Bestand weergeven

@@ -0,0 +1,15 @@
# Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
#
# Licensed under The MIT License
# For full copyright and license information, please see the LICENSE.txt
# Redistributions of files must retain the above copyright notice.
# MIT License (https://opensource.org/licenses/mit-license.php)

CREATE TABLE `sessions` (
`id` char(40) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`created` datetime DEFAULT CURRENT_TIMESTAMP, -- optional, requires MySQL 5.6.5+
`modified` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- optional, requires MySQL 5.6.5+
`data` blob DEFAULT NULL, -- for PostgreSQL use bytea instead of blob
`expires` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

+ 16
- 0
app/index.php Bestand weergeven

@@ -0,0 +1,16 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/

require 'webroot' . DIRECTORY_SEPARATOR . 'index.php';

+ 10
- 0
app/phpcs.xml Bestand weergeven

@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<ruleset name="App">
<rule ref="CakePHP"/>
<rule ref="SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint">
<exclude-pattern>*/src/Controller/*</exclude-pattern>
</rule>

<file>src/</file>
<file>tests/</file>
</ruleset>

+ 7
- 0
app/phpstan.neon Bestand weergeven

@@ -0,0 +1,7 @@
parameters:
level: 8
treatPhpDocTypesAsCertain: false
bootstrapFiles:
- config/bootstrap.php
paths:
- src/

+ 36
- 0
app/phpunit.xml.dist Bestand weergeven

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
colors="true"
processIsolation="false"
stopOnFailure="false"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd">
<php>
<ini name="memory_limit" value="-1"/>
</php>

<!-- Add any additional test suites you want to run here -->
<testsuites>
<testsuite name="app">
<directory>tests/TestCase/</directory>
</testsuite>
<!-- Add plugin test suites here. -->
</testsuites>

<!-- Load extension for fixtures -->
<extensions>
<bootstrap class="Cake\TestSuite\Fixture\Extension\PHPUnitExtension"/>
</extensions>

<!-- Ignore vendor tests in code coverage reports -->
<source>
<include>
<directory suffix=".php">src/</directory>
<directory suffix=".php">plugins/*/src/</directory>
</include>
<exclude>
<file>src/Console/Installer.php</file>
</exclude>
</source>
</phpunit>

+ 0
- 0
app/plugins/.gitkeep Bestand weergeven


+ 15
- 0
app/psalm.xml Bestand weergeven

@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<psalm
errorLevel="2"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
>
<projectFiles>
<directory name="src/"/>
<ignoreFiles>
<directory name="vendor/"/>
</ignoreFiles>
</projectFiles>
</psalm>

+ 0
- 0
app/resources/.gitkeep Bestand weergeven


+ 131
- 0
app/src/Application.php Bestand weergeven

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.3.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App;

use App\Middleware\HostHeaderMiddleware;
use Cake\Core\Configure;
use Cake\Core\ContainerInterface;
use Cake\Datasource\FactoryLocator;
use Cake\Error\Middleware\ErrorHandlerMiddleware;
use Cake\Event\EventManagerInterface;
use Cake\Http\BaseApplication;
use Cake\Http\Middleware\BodyParserMiddleware;
use Cake\Http\Middleware\CsrfProtectionMiddleware;
use Cake\Http\MiddlewareQueue;
use Cake\ORM\Locator\TableLocator;
use Cake\Routing\Middleware\AssetMiddleware;
use Cake\Routing\Middleware\RoutingMiddleware;

/**
* Application setup class.
*
* This defines the bootstrapping logic and middleware layers you
* want to use in your application.
*
* @extends \Cake\Http\BaseApplication<\App\Application>
*/
class Application extends BaseApplication
{
/**
* Load all the application configuration and bootstrap logic.
*
* @return void
*/
public function bootstrap(): void
{
// Call parent to load bootstrap from files.
parent::bootstrap();

$this->addPlugin('Geo');

// By default, does not allow fallback classes.
FactoryLocator::add(
'Table',
(new TableLocator())->allowFallbackClass(false),
);
}

/**
* Setup the middleware queue your application will use.
*
* @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to setup.
* @return \Cake\Http\MiddlewareQueue The updated middleware queue.
*/
public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
{
$middlewareQueue
// Catch any exceptions in the lower layers,
// and make an error page/response
->add(new ErrorHandlerMiddleware(Configure::read('Error'), $this))

// Validate Host header to prevent Host Header Injection attacks.
// In production, ensures App.fullBaseUrl is configured and validates
// the incoming Host header against it.
->add(new HostHeaderMiddleware())

// Handle plugin/theme assets like CakePHP normally does.
->add(new AssetMiddleware([
'cacheTime' => Configure::read('Asset.cacheTime'),
]))

// Add routing middleware.
// If you have a large number of routes connected, turning on routes
// caching in production could improve performance.
// See https://github.com/CakeDC/cakephp-cached-routing
->add(new RoutingMiddleware($this))

// Parse various types of encoded request bodies so that they are
// available as array through $request->getData()
// https://book.cakephp.org/5/en/controllers/middleware.html#body-parser-middleware
->add(new BodyParserMiddleware())

// Cross Site Request Forgery (CSRF) Protection Middleware
// https://book.cakephp.org/5/en/security/csrf.html#cross-site-request-forgery-csrf-middleware
->add(new CsrfProtectionMiddleware([
'httponly' => true,
]));

return $middlewareQueue;
}

/**
* Register application container services.
*
* @param \Cake\Core\ContainerInterface $container The Container to update.
* @return void
* @link https://book.cakephp.org/5/en/development/dependency-injection.html#dependency-injection
*/
public function services(ContainerInterface $container): void
{
// Allow your Tables to be dependency injected
//$container->delegate(new \Cake\ORM\Locator\TableContainer());
}

/**
* Register custom event listeners here
*
* @param \Cake\Event\EventManagerInterface $eventManager
* @return \Cake\Event\EventManagerInterface
* @link https://book.cakephp.org/5/en/core-libraries/events.html#registering-listeners
*/
public function events(EventManagerInterface $eventManager): EventManagerInterface
{
// $eventManager->on(new SomeCustomListenerClass());

return $eventManager;
}
}

+ 260
- 0
app/src/Console/Installer.php Bestand weergeven

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Console;

if (!defined('STDIN')) {
define('STDIN', fopen('php://stdin', 'r'));
}

use Cake\Codeception\Console\Installer as CodeceptionInstaller;
use Cake\Utility\Security;
use Composer\IO\IOInterface;
use Composer\Script\Event;
use Exception;

/**
* Provides installation hooks for when this application is installed through
* composer. Customize this class to suit your needs.
*/
class Installer
{
/**
* An array of directories to be made writable
*
* @var list<string>
*/
public const WRITABLE_DIRS = [
'logs',
'tmp',
'tmp/cache',
'tmp/cache/models',
'tmp/cache/persistent',
'tmp/cache/views',
'tmp/sessions',
'tmp/tests',
];

/**
* Does some routine installation tasks so people don't have to.
*
* @param \Composer\Script\Event $event The composer event object.
* @throws \Exception Exception raised by validator.
* @return void
*/
public static function postInstall(Event $event): void
{
$io = $event->getIO();

$rootDir = dirname(__DIR__, 2);

static::createAppLocalConfig($rootDir, $io);
static::createWritableDirectories($rootDir, $io);

static::setFolderPermissions($rootDir, $io);
static::setSecuritySalt($rootDir, $io);

if (class_exists(CodeceptionInstaller::class)) {
CodeceptionInstaller::customizeCodeceptionBinary($event);
}
}

/**
* Create config/app_local.php file if it does not exist.
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @return void
*/
public static function createAppLocalConfig(string $dir, IOInterface $io): void
{
$appLocalConfig = $dir . '/config/app_local.php';
$appLocalConfigTemplate = $dir . '/config/app_local.example.php';
if (!file_exists($appLocalConfig)) {
copy($appLocalConfigTemplate, $appLocalConfig);
$io->write('Created `config/app_local.php` file');
}
}

/**
* Create the `logs` and `tmp` directories.
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @return void
*/
public static function createWritableDirectories(string $dir, IOInterface $io): void
{
foreach (static::WRITABLE_DIRS as $path) {
$path = $dir . '/' . $path;
if (!file_exists($path)) {
mkdir($path);
$io->write('Created `' . $path . '` directory');
}
}
}

/**
* Set globally writable permissions on the "tmp" and "logs" directory.
*
* This is not the most secure default, but it gets people up and running quickly.
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @return void
*/
public static function setFolderPermissions(string $dir, IOInterface $io): void
{
// ask if the permissions should be changed
if ($io->isInteractive()) {
$validator = function (string $arg): string {
if (in_array($arg, ['Y', 'y', 'N', 'n'])) {
return $arg;
}
throw new Exception('This is not a valid answer. Please choose Y or n.');
};
$setFolderPermissions = $io->askAndValidate(
'<info>Set Folder Permissions ? (Default to Y)</info> [<comment>Y,n</comment>]? ',
$validator,
10,
'Y',
);

if (in_array($setFolderPermissions, ['n', 'N'])) {
return;
}
}

// Change the permissions on a path and output the results.
$changePerms = function (string $path) use ($io): void {
$currentPerms = fileperms($path) & 0777;
$worldWritable = $currentPerms | 0007;
if ($worldWritable == $currentPerms) {
return;
}

$res = chmod($path, $worldWritable);
if ($res) {
$io->write('Permissions set on ' . $path);
} else {
$io->write('Failed to set permissions on ' . $path);
}
};

$walker = function (string $dir) use (&$walker, $changePerms): void {
$files = array_diff(scandir($dir) ?: [], ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;

if (!is_dir($path)) {
continue;
}

$changePerms($path);
$walker($path);
}
};

$walker($dir . '/tmp');
$changePerms($dir . '/tmp');
$changePerms($dir . '/logs');
}

/**
* Set the security.salt value in the application's config file.
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @return void
*/
public static function setSecuritySalt(string $dir, IOInterface $io): void
{
$newKey = hash('sha256', Security::randomBytes(64));
static::setSecuritySaltInFile($dir, $io, $newKey, 'app_local.php');
}

/**
* Set the security.salt value in a given file
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @param string $newKey key to set in the file
* @param string $file A path to a file relative to the application's root
* @return void
*/
public static function setSecuritySaltInFile(string $dir, IOInterface $io, string $newKey, string $file): void
{
$config = $dir . '/config/' . $file;
$content = file_get_contents($config);
if ($content === false) {
$io->write('Config file not readable or not found: config/' . $file);

return;
}

$content = str_replace('__SALT__', $newKey, $content, $count);

if ($count == 0) {
$io->write('No Security.salt placeholder to replace.');

return;
}

$result = file_put_contents($config, $content);
if ($result) {
$io->write('Updated Security.salt value in config/' . $file);

return;
}
$io->write('Unable to update Security.salt value.');
}

/**
* Set the APP_NAME value in a given file
*
* @param string $dir The application's root directory.
* @param \Composer\IO\IOInterface $io IO interface to write to console.
* @param string $appName app name to set in the file
* @param string $file A path to a file relative to the application's root
* @return void
*/
public static function setAppNameInFile(string $dir, IOInterface $io, string $appName, string $file): void
{
$config = $dir . '/config/' . $file;
$content = file_get_contents($config);
if ($content === false) {
$io->write('Config file not readable or not found: config/' . $file);

return;
}

$content = str_replace('__APP_NAME__', $appName, $content, $count);

if ($count == 0) {
$io->write('No __APP_NAME__ placeholder to replace.');

return;
}

$result = file_put_contents($config, $content);
if ($result) {
$io->write('Updated __APP_NAME__ value in config/' . $file);

return;
}
$io->write('Unable to update __APP_NAME__ value.');
}
}

+ 52
- 0
app/src/Controller/AppController.php Bestand weergeven

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.2.9
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Controller;

use Cake\Controller\Controller;

/**
* Application Controller
*
* Add your application-wide methods in the class below, your controllers
* will inherit them.
*
* @link https://book.cakephp.org/5/en/controllers.html#the-app-controller
*/
class AppController extends Controller
{
/**
* Initialization hook method.
*
* Use this method to add common initialization code like loading components.
*
* e.g. `$this->loadComponent('FormProtection');`
*
* @return void
*/
public function initialize(): void
{
parent::initialize();

$this->loadComponent('Flash');

/*
* Enable the following component for recommended CakePHP form protection settings.
* see https://book.cakephp.org/5/en/controllers/components/form-protection.html
*/
//$this->loadComponent('FormProtection');
}
}

+ 0
- 0
app/src/Controller/Component/.gitkeep Bestand weergeven


+ 70
- 0
app/src/Controller/ErrorController.php Bestand weergeven

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.3.4
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Controller;

use Cake\Event\EventInterface;

/**
* Error Handling Controller
*
* Controller used by ExceptionRenderer to render error responses.
*/
class ErrorController extends AppController
{
/**
* Initialization hook method.
*
* @return void
*/
public function initialize(): void
{
// Only add parent::initialize() if you are confident your `AppController` is safe.
}

/**
* beforeFilter callback.
*
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event.
* @return void
*/
public function beforeFilter(EventInterface $event): void
{
}

/**
* beforeRender callback.
*
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event.
* @return void
*/
public function beforeRender(EventInterface $event): void
{
parent::beforeRender($event);

$this->viewBuilder()->setTemplatePath('Error');
}

/**
* afterFilter callback.
*
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event.
* @return void
*/
public function afterFilter(EventInterface $event): void
{
}
}

+ 73
- 0
app/src/Controller/PagesController.php Bestand weergeven

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.2.9
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Controller;

use Cake\Core\Configure;
use Cake\Http\Exception\ForbiddenException;
use Cake\Http\Exception\NotFoundException;
use Cake\Http\Response;
use Cake\View\Exception\MissingTemplateException;

/**
* Static content controller
*
* This controller will render views from templates/Pages/
*
* @link https://book.cakephp.org/5/en/controllers/pages-controller.html
*/
class PagesController extends AppController
{
/**
* Displays a view
*
* @param string ...$path Path segments.
* @return \Cake\Http\Response|null
* @throws \Cake\Http\Exception\ForbiddenException When a directory traversal attempt.
* @throws \Cake\View\Exception\MissingTemplateException When the view file could not
* be found and in debug mode.
* @throws \Cake\Http\Exception\NotFoundException When the view file could not
* be found and not in debug mode.
* @throws \Cake\View\Exception\MissingTemplateException In debug mode.
*/
public function display(string ...$path): ?Response
{
if (!$path) {
return $this->redirect('/');
}
if (in_array('..', $path, true) || in_array('.', $path, true)) {
throw new ForbiddenException();
}
$page = $subpage = null;

if (!empty($path[0])) {
$page = $path[0];
}
if (!empty($path[1])) {
$subpage = $path[1];
}
$this->set(compact('page', 'subpage'));

try {
return $this->render(implode('/', $path));
} catch (MissingTemplateException $exception) {
if (Configure::read('debug')) {
throw $exception;
}
throw new NotFoundException();
}
}
}

+ 137
- 0
app/src/Controller/TerritoriesController.php Bestand weergeven

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);

namespace App\Controller;

use App\Service\OverpassService;
use RuntimeException;

/**
* Territories Controller
*
* Serves the territory map and a small JSON API used by the map's
* draw/edit/delete controls to persist polygons, plus a lookup that
* finds home addresses inside a territory via OpenStreetMap.
*
* @property \App\Model\Table\TerritoriesTable $Territories
*/
class TerritoriesController extends AppController
{
/**
* Map view listing all saved territories and any addresses already found for them.
*
* @return void
*/
public function index(): void
{
$territories = $this->Territories->find()->contain(['Addresses'])->all();
$this->set(compact('territories'));
}

/**
* Create a territory from a drawn polygon.
*
* @return \Cake\Http\Response
*/
public function add()
{
$this->request->allowMethod(['post']);

$territory = $this->Territories->newEntity($this->request->getData());
$saved = $this->Territories->save($territory);

return $this->jsonResponse($saved ? $territory : null, $territory->getErrors());
}

/**
* Update a territory's polygon or name (from the map's edit control).
*
* @param string|null $id Territory id.
* @return \Cake\Http\Response
*/
public function edit($id = null)
{
$this->request->allowMethod(['post', 'put', 'patch']);

$territory = $this->Territories->get($id);
$territory = $this->Territories->patchEntity($territory, $this->request->getData());
$saved = $this->Territories->save($territory);

return $this->jsonResponse($saved ? $territory : null, $territory->getErrors());
}

/**
* Delete a territory (from the map's delete control).
*
* @param string|null $id Territory id.
* @return \Cake\Http\Response
*/
public function delete($id = null)
{
$this->request->allowMethod(['post', 'delete']);

$territory = $this->Territories->get($id);
$deleted = $this->Territories->delete($territory);

return $this->jsonResponse($deleted ? ['id' => $territory->id] : null);
}

/**
* Look up home addresses inside a territory's polygon via OpenStreetMap
* (Overpass API) and cache them against the territory.
*
* @param string|null $id Territory id.
* @return \Cake\Http\Response
*/
public function addresses($id = null)
{
$this->request->allowMethod(['post']);

$territory = $this->Territories->get($id);
$ring = $territory->polygon['coordinates'][0] ?? [];

if (!$ring) {
return $this->jsonResponse(null, ['polygon' => ['Territory has no polygon geometry.']]);
}

try {
$found = (new OverpassService())->findAddressesInPolygon($ring);
} catch (RuntimeException $e) {
return $this->jsonResponse(null, ['overpass' => [$e->getMessage()]]);
}

$Addresses = $this->Territories->Addresses;
foreach ($found as $data) {
$data['territory_id'] = $territory->id;
$entity = $Addresses->find()
->where([
'territory_id' => $territory->id,
'osm_type' => $data['osm_type'],
'osm_id' => $data['osm_id'],
])
->first() ?: $Addresses->newEmptyEntity();
$Addresses->save($Addresses->patchEntity($entity, $data));
}

$addresses = $Addresses->find()->where(['territory_id' => $territory->id])->all();

return $this->jsonResponse(['territory_id' => $territory->id, 'addresses' => $addresses]);
}

/**
* @param mixed $data Payload to return on success.
* @param array $errors Validation errors to return on failure.
* @return \Cake\Http\Response
*/
protected function jsonResponse(mixed $data, array $errors = [])
{
$body = $data !== null
? ['success' => true, 'data' => $data]
: ['success' => false, 'errors' => $errors];

return $this->response
->withType('application/json')
->withStatus($data !== null ? 200 : 422)
->withStringBody((string)json_encode($body));
}
}

+ 58
- 0
app/src/Middleware/HostHeaderMiddleware.php Bestand weergeven

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);

namespace App\Middleware;

use Cake\Core\Configure;
use Cake\Http\Exception\BadRequestException;
use Cake\Http\Exception\InternalErrorException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
* Middleware to validate Host header and prevent Host Header Injection attacks.
*
* In production, this middleware ensures that App.fullBaseUrl is configured
* and validates incoming Host headers against it. This prevents attackers
* from manipulating password reset links and other security-critical URLs.
*
* @see https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/17-Testing_for_Host_Header_Injection
*/
class HostHeaderMiddleware implements MiddlewareInterface
{
/**
* Process the request and validate the Host header.
*
* @param \Psr\Http\Message\ServerRequestInterface $request The request.
* @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler.
* @return \Psr\Http\Message\ResponseInterface A response.
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (Configure::read('debug')) {
return $handler->handle($request);
}

$fullBaseUrl = Configure::read('App.fullBaseUrl');
if (!$fullBaseUrl) {
throw new InternalErrorException(
'SECURITY: App.fullBaseUrl is not configured. ' .
'This is required in production to prevent Host Header Injection attacks. ' .
'Set APP_FULL_BASE_URL environment variable or configure App.fullBaseUrl in config/app.php',
);
}

$configuredHost = parse_url($fullBaseUrl, PHP_URL_HOST);
$requestHost = $request->getUri()->getHost();

if ($configuredHost && $requestHost && strtolower($configuredHost) !== strtolower($requestHost)) {
throw new BadRequestException(
'Invalid Host header. Request host does not match configured application host.',
);
}

return $handler->handle($request);
}
}

+ 0
- 0
app/src/Model/Behavior/.gitkeep Bestand weergeven


+ 47
- 0
app/src/Model/Entity/Address.php Bestand weergeven

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);

namespace App\Model\Entity;

use Cake\ORM\Entity;

/**
* Address Entity
*
* @property int $id
* @property int $territory_id
* @property string $osm_type
* @property int $osm_id
* @property string|null $house_number
* @property string|null $street
* @property string $full_address
* @property string $lat
* @property string $lng
* @property \Cake\I18n\DateTime $created
*
* @property \App\Model\Entity\Territory $territory
*/
class Address extends Entity
{
/**
* Fields that can be mass assigned using newEntity() or patchEntity().
*
* Note that when '*' is set to true, this allows all unspecified fields to
* be mass assigned. For security purposes, it is advised to set '*' to false
* (or remove it), and explicitly make individual fields accessible as needed.
*
* @var array<string, bool>
*/
protected array $_accessible = [
'territory_id' => true,
'osm_type' => true,
'osm_id' => true,
'house_number' => true,
'street' => true,
'full_address' => true,
'lat' => true,
'lng' => true,
'created' => true,
'territory' => true,
];
}

+ 34
- 0
app/src/Model/Entity/Territory.php Bestand weergeven

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);

namespace App\Model\Entity;

use Cake\ORM\Entity;

/**
* Territory Entity
*
* @property int $id
* @property string $name
* @property array $polygon
* @property \Cake\I18n\DateTime $created
* @property \Cake\I18n\DateTime $modified
*/
class Territory extends Entity
{
/**
* Fields that can be mass assigned using newEntity() or patchEntity().
*
* Note that when '*' is set to true, this allows all unspecified fields to
* be mass assigned. For security purposes, it is advised to set '*' to false
* (or remove it), and explicitly make individual fields accessible as needed.
*
* @var array<string, bool>
*/
protected array $_accessible = [
'name' => true,
'polygon' => true,
'created' => true,
'modified' => true,
];
}

+ 121
- 0
app/src/Model/Table/AddressesTable.php Bestand weergeven

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\ORM\Query\SelectQuery;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
* Addresses Model
*
* @property \App\Model\Table\TerritoriesTable&\Cake\ORM\Association\BelongsTo $Territories
*
* @method \App\Model\Entity\Address newEmptyEntity()
* @method \App\Model\Entity\Address newEntity(array $data, array $options = [])
* @method array<\App\Model\Entity\Address> newEntities(array $data, array $options = [])
* @method \App\Model\Entity\Address get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args)
* @method \App\Model\Entity\Address findOrCreate($search, ?callable $callback = null, array $options = [])
* @method \App\Model\Entity\Address patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
* @method array<\App\Model\Entity\Address> patchEntities(iterable $entities, array $data, array $options = [])
* @method \App\Model\Entity\Address|false save(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method \App\Model\Entity\Address saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method iterable<\App\Model\Entity\Address>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\Address>|false saveMany(iterable $entities, array $options = [])
* @method iterable<\App\Model\Entity\Address>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\Address> saveManyOrFail(iterable $entities, array $options = [])
* @method iterable<\App\Model\Entity\Address>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\Address>|false deleteMany(iterable $entities, array $options = [])
* @method iterable<\App\Model\Entity\Address>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\Address> deleteManyOrFail(iterable $entities, array $options = [])
*
* @mixin \Cake\ORM\Behavior\TimestampBehavior
*/
class AddressesTable extends Table
{
/**
* Initialize method
*
* @param array<string, mixed> $config The configuration for the Table.
* @return void
*/
public function initialize(array $config): void
{
parent::initialize($config);

$this->setTable('addresses');
$this->setDisplayField('osm_type');
$this->setPrimaryKey('id');

$this->addBehavior('Timestamp');

$this->belongsTo('Territories', [
'foreignKey' => 'territory_id',
'joinType' => 'INNER',
]);
}

/**
* Default validation rules.
*
* @param \Cake\Validation\Validator $validator Validator instance.
* @return \Cake\Validation\Validator
*/
public function validationDefault(Validator $validator): Validator
{
$validator
->integer('territory_id')
->notEmptyString('territory_id');

$validator
->scalar('osm_type')
->maxLength('osm_type', 255)
->requirePresence('osm_type', 'create')
->notEmptyString('osm_type');

$validator
->requirePresence('osm_id', 'create')
->notEmptyString('osm_id');

$validator
->scalar('house_number')
->maxLength('house_number', 255)
->allowEmptyString('house_number');

$validator
->scalar('street')
->maxLength('street', 255)
->allowEmptyString('street');

$validator
->scalar('full_address')
->maxLength('full_address', 255)
->requirePresence('full_address', 'create')
->notEmptyString('full_address');

$validator
->decimal('lat')
->requirePresence('lat', 'create')
->notEmptyString('lat');

$validator
->decimal('lng')
->requirePresence('lng', 'create')
->notEmptyString('lng');

return $validator;
}

/**
* Returns a rules checker object that will be used for validating
* application integrity.
*
* @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
* @return \Cake\ORM\RulesChecker
*/
public function buildRules(RulesChecker $rules): RulesChecker
{
$rules->add($rules->isUnique(['territory_id', 'osm_type', 'osm_id']), ['errorField' => 'territory_id', 'message' => __('This combination of territory_id, osm_type and osm_id already exists')]);
$rules->add($rules->existsIn(['territory_id'], 'Territories'), ['errorField' => 'territory_id']);

return $rules;
}
}

+ 82
- 0
app/src/Model/Table/TerritoriesTable.php Bestand weergeven

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\ORM\Query\SelectQuery;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
* Territories Model
*
* @method \App\Model\Entity\Territory newEmptyEntity()
* @method \App\Model\Entity\Territory newEntity(array $data, array $options = [])
* @method array<\App\Model\Entity\Territory> newEntities(array $data, array $options = [])
* @method \App\Model\Entity\Territory get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args)
* @method \App\Model\Entity\Territory findOrCreate($search, ?callable $callback = null, array $options = [])
* @method \App\Model\Entity\Territory patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
* @method array<\App\Model\Entity\Territory> patchEntities(iterable $entities, array $data, array $options = [])
* @method \App\Model\Entity\Territory|false save(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method \App\Model\Entity\Territory saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = [])
* @method iterable<\App\Model\Entity\Territory>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\Territory>|false saveMany(iterable $entities, array $options = [])
* @method iterable<\App\Model\Entity\Territory>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\Territory> saveManyOrFail(iterable $entities, array $options = [])
* @method iterable<\App\Model\Entity\Territory>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\Territory>|false deleteMany(iterable $entities, array $options = [])
* @method iterable<\App\Model\Entity\Territory>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\Territory> deleteManyOrFail(iterable $entities, array $options = [])
*
* @mixin \Cake\ORM\Behavior\TimestampBehavior
*/
class TerritoriesTable extends Table
{
/**
* Initialize method
*
* @param array<string, mixed> $config The configuration for the Table.
* @return void
*/
public function initialize(array $config): void
{
parent::initialize($config);

$this->setTable('territories');
$this->setDisplayField('name');
$this->setPrimaryKey('id');

$this->addBehavior('Timestamp');

$this->hasMany('Addresses', [
'foreignKey' => 'territory_id',
'dependent' => true,
]);
}

/**
* Default validation rules.
*
* @param \Cake\Validation\Validator $validator Validator instance.
* @return \Cake\Validation\Validator
*/
public function validationDefault(Validator $validator): Validator
{
$validator
->scalar('name')
->maxLength('name', 255)
->requirePresence('name', 'create')
->notEmptyString('name');

$validator
->requirePresence('polygon', 'create')
->notEmptyString('polygon')
->add('polygon', 'validGeometry', [
'rule' => function ($value) {
return is_array($value)
&& ($value['type'] ?? null) === 'Polygon'
&& !empty($value['coordinates']);
},
'message' => 'Polygon must be a GeoJSON Polygon geometry.',
]);

return $validator;
}
}

+ 153
- 0
app/src/Service/OverpassService.php Bestand weergeven

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);

namespace App\Service;

use Cake\Http\Client;
use RuntimeException;

/**
* Finds addressed OpenStreetMap nodes/ways (i.e. homes) inside a polygon
* via the public Overpass API.
*
* @link https://wiki.openstreetmap.org/wiki/Overpass_API
*/
class OverpassService
{
/**
* Public Overpass mirrors to try in order. The main overpass-api.de
* instance is shared/free and frequently reports itself as too busy,
* so we fail over to alternate mirrors rather than erroring immediately.
*
* @var array<int, string>
*/
protected array $endpoints = [
'https://overpass-api.de/api/interpreter',
'https://overpass.kumi.systems/api/interpreter',
'https://overpass.private.coffee/api/interpreter',
];

/**
* Attempts per endpoint before moving to the next mirror.
*
* @var int
*/
protected int $attemptsPerEndpoint = 2;

/**
* Backoff (seconds) before each retry of the same endpoint. Index 0 is
* the delay before the 2nd attempt, index 1 before the 3rd, etc.
*
* @var array<int, int>
*/
protected array $backoffSeconds = [1, 2, 4];

protected Client $client;

public function __construct(?Client $client = null)
{
$this->client = $client ?? new Client();
}

/**
* @param array<int, array<int, float>> $ring GeoJSON-style ring: a list of [lng, lat] pairs.
* @return array<int, array<string, mixed>>
*/
public function findAddressesInPolygon(array $ring): array
{
$poly = $this->buildPolyFilter($ring);

$query = <<<OVERPASS
[out:json][timeout:25];
(
node["addr:housenumber"](poly:"{$poly}");
way["addr:housenumber"](poly:"{$poly}");
);
out center tags;
OVERPASS;

$elements = (array)($this->request($query)['elements'] ?? []);

$addresses = [];
foreach ($elements as $element) {
$lat = $element['lat'] ?? $element['center']['lat'] ?? null;
$lng = $element['lon'] ?? $element['center']['lon'] ?? null;
if ($lat === null || $lng === null) {
continue;
}

$tags = $element['tags'] ?? [];
$houseNumber = $tags['addr:housenumber'] ?? null;
$street = $tags['addr:street'] ?? null;
$fullAddress = trim(($houseNumber ?? '') . ' ' . ($street ?? ''));

$addresses[] = [
'osm_type' => $element['type'],
'osm_id' => $element['id'],
'house_number' => $houseNumber,
'street' => $street,
'full_address' => $fullAddress !== '' ? $fullAddress : 'Unnamed address',
'lat' => $lat,
'lng' => $lng,
];
}

return $addresses;
}

/**
* Try each configured Overpass mirror in turn, returning the first
* successful decoded JSON response.
*
* @param string $query
* @return array<string, mixed>
*/
protected function request(string $query): array
{
$lastError = null;

foreach ($this->endpoints as $endpoint) {
for ($attempt = 1; $attempt <= $this->attemptsPerEndpoint; $attempt++) {
if ($attempt > 1) {
sleep($this->backoffSeconds[$attempt - 2] ?? end($this->backoffSeconds));
}

try {
$response = $this->client->post($endpoint, ['data' => $query], [
'headers' => [
'User-Agent' => 'CakePhpTerritoryApp/1.0',
],
'timeout' => 20,
]);
} catch (\Exception $e) {
$lastError = $e->getMessage();

continue;
}

if ($response->isOk()) {
return (array)$response->getJson();
}

$lastError = 'HTTP ' . $response->getStatusCode() . ' from ' . $endpoint;
}
}

throw new RuntimeException('Overpass API request failed: ' . $lastError);
}

/**
* @param array<int, array<int, float>> $ring
* @return string
*/
protected function buildPolyFilter(array $ring): string
{
$pairs = [];
foreach ($ring as $point) {
[$lng, $lat] = $point;
$pairs[] = $lat . ' ' . $lng;
}

return implode(' ', $pairs);
}
}

+ 46
- 0
app/src/View/AjaxView.php Bestand weergeven

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.4
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\View;

/**
* A view class that is used for AJAX responses.
* Currently only switches the default layout and sets the response type -
* which just maps to text/html by default.
*/
class AjaxView extends AppView
{
/**
* The name of the layout file to render the view inside of. The name
* specified is the filename of the layout in /templates/Layout without
* the .php extension.
*
* @var string
*/
protected string $layout = 'ajax';

/**
* Initialization hook method.
*
* @return void
*/
public function initialize(): void
{
parent::initialize();

$this->response = $this->response->withType('ajax');
}
}

+ 42
- 0
app/src/View/AppView.php Bestand weergeven

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\View;

use Cake\View\View;

/**
* Application View
*
* Your application's default view class
*
* @link https://book.cakephp.org/5/en/views.html#the-app-view
*/
class AppView extends View
{
/**
* Initialization hook method.
*
* Use this method to add common initialization code like adding helpers.
*
* e.g. `$this->addHelper('Html');`
*
* @return void
*/
public function initialize(): void
{
$this->addHelper('Geo.Leaflet');
}
}

+ 0
- 0
app/src/View/Cell/.gitkeep Bestand weergeven


+ 0
- 0
app/src/View/Helper/.gitkeep Bestand weergeven


+ 26
- 0
app/templates/Error/error400.php Bestand weergeven

@@ -0,0 +1,26 @@
<?php
/**
* @var \App\View\AppView $this
* @var string $message
* @var string $url
*/
use Cake\Core\Configure;

$this->setLayout('error');

if (Configure::read('debug')) :
$this->setLayout('dev_error');

$this->assign('title', $message);
$this->assign('templateName', 'error400.php');

$this->start('file');
echo $this->element('auto_table_warning');
$this->end();
endif;
?>
<h2><?= h($message) ?></h2>
<p class="error">
<strong><?= __d('cake', 'Error') ?>: </strong>
<?= __d('cake', 'The requested address {0} was not found on this server.', "<strong>'{$url}'</strong>") ?>
</p>

+ 36
- 0
app/templates/Error/error500.php Bestand weergeven

@@ -0,0 +1,36 @@
<?php
/**
* @var \App\View\AppView $this
* @var string $message
* @var string $url
*/
use Cake\Core\Configure;
use Cake\Error\Debugger;

$this->setLayout('error');

if (Configure::read('debug')) :
$this->setLayout('dev_error');

$this->assign('title', $message);
$this->assign('templateName', 'error500.php');

$this->start('file');
?>
<?php if ($error instanceof Error) : ?>
<?php $file = $error->getFile() ?>
<?php $line = $error->getLine() ?>
<strong>Error in: </strong>
<?= $this->Html->link(sprintf('%s, line %s', Debugger::trimPath($file), $line), Debugger::editorUrl($file, $line)); ?>
<?php endif; ?>
<?php
echo $this->element('auto_table_warning');

$this->end();
endif;
?>
<h2><?= __d('cake', 'An Internal Error Has Occurred.') ?></h2>
<p class="error">
<strong><?= __d('cake', 'Error') ?>: </strong>
<?= h($message) ?>
</p>

+ 21
- 0
app/templates/Pages/home.php Bestand weergeven

@@ -0,0 +1,21 @@
<?php
/**
* @var \App\View\AppView $this
*/
$this->assign('title', 'Home');
?>
<div class="content">
<h1>Welcome</h1>
<p>This page is rendered through the default layout, so the header and footer below are shared across every page.</p>

<?= $this->Leaflet->map() ?>
<?php
$this->Leaflet->addMarker([
'lat' => 42.9634,
'lng' => -85.6681,
'title' => 'Grand Rapids, MI',
'content' => 'Grand Rapids, MI',
]);
$this->Leaflet->finalize();
?>
</div>

+ 220
- 0
app/templates/Territories/index.php Bestand weergeven

@@ -0,0 +1,220 @@
<?php
/**
* @var \App\View\AppView $this
* @var iterable<\App\Model\Entity\Territory> $territories
*/
$this->assign('title', 'Territories');

$territoriesData = [];
foreach ($territories as $territory) {
$addresses = [];
foreach ($territory->addresses as $address) {
$addresses[] = [
'id' => $address->id,
'full_address' => $address->full_address,
'lat' => (float)$address->lat,
'lng' => (float)$address->lng,
];
}
$territoriesData[] = [
'id' => $territory->id,
'name' => $territory->name,
'polygon' => $territory->polygon,
'addresses' => $addresses,
];
}
$territoriesJson = str_replace('</', '<\/', (string)json_encode($territoriesData));

$mapVar = $this->Leaflet->name();
?>
<div class="content">
<div class="page-header">
<h1>Territories</h1>
<p>Use the polygon tool on the map to draw a new territory. Use the edit/delete tools to update or remove one. Use "Find addresses" to pull home addresses inside a territory from OpenStreetMap.</p>
</div>

<?= $this->Leaflet->map(['div' => ['id' => 'territories-map', 'height' => '600px']]) ?>

<?php
// Must be registered after Leaflet->map() so leaflet.js (which leaflet-draw.js extends) loads first.
?>
<?= $this->Html->css('https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css', ['block' => true]) ?>
<?= $this->Html->script('https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js', ['block' => true]) ?>

<?php
$this->Leaflet->addCustom(<<<JS
var drawnItems = new L.FeatureGroup();
{$mapVar}.addLayer(drawnItems);

var addressIcon = L.divIcon({
className: 'address-marker-icon',
html: '⌂',
iconSize: [22, 22],
iconAnchor: [11, 11]
});

var addressLayers = {};

function renderAddresses(territoryId, addresses) {
if (addressLayers[territoryId]) {
{$mapVar}.removeLayer(addressLayers[territoryId]);
}
var group = L.layerGroup();
addresses.forEach(function (a) {
L.marker([a.lat, a.lng], { icon: addressIcon })
.bindPopup(a.full_address)
.addTo(group);
});
group.addTo({$mapVar});
addressLayers[territoryId] = group;
}

var territoryData = {$territoriesJson};

territoryData.forEach(function (t) {
var layer = L.geoJSON(t.polygon).getLayers()[0];
layer.territoryId = t.id;
layer.bindPopup(t.name);
drawnItems.addLayer(layer);
renderAddresses(t.id, t.addresses);
});

var drawControl = new L.Control.Draw({
draw: {
polygon: true,
polyline: false,
rectangle: false,
circle: false,
circlemarker: false,
marker: false
},
edit: {
featureGroup: drawnItems
}
});
{$mapVar}.addControl(drawControl);

var csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

function territoryApiRequest(url, method, body) {
return fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
'Accept': 'application/json'
},
body: JSON.stringify(body)
}).then(function (res) { return res.json(); });
}

{$mapVar}.on('draw:created', function (e) {
var layer = e.layer;
var name = window.prompt('Name this territory:');
if (!name) {
return;
}
territoryApiRequest('/territories/add', 'POST', {
name: name,
polygon: layer.toGeoJSON().geometry
}).then(function (res) {
if (res.success) {
layer.territoryId = res.data.id;
layer.bindPopup(res.data.name);
drawnItems.addLayer(layer);
} else {
alert('Could not save territory.');
}
});
});

{$mapVar}.on('draw:edited', function (e) {
e.layers.eachLayer(function (layer) {
if (!layer.territoryId) {
return;
}
territoryApiRequest('/territories/edit/' + layer.territoryId, 'POST', {
polygon: layer.toGeoJSON().geometry
}).then(function (res) {
if (!res.success) {
alert('Could not update territory.');
}
});
});
});

{$mapVar}.on('draw:deleted', function (e) {
e.layers.eachLayer(function (layer) {
if (!layer.territoryId) {
return;
}
territoryApiRequest('/territories/delete/' + layer.territoryId, 'POST', {}).then(function (res) {
if (!res.success) {
alert('Could not delete territory.');
}
});
});
});

document.addEventListener('click', function (e) {
var button = e.target.closest('.js-find-addresses');
if (!button) {
return;
}
var territoryId = button.getAttribute('data-territory-id');
var countEl = document.querySelector('.js-address-count[data-territory-id="' + territoryId + '"]');
button.disabled = true;
button.textContent = 'Searching…';
territoryApiRequest('/territories/addresses/' + territoryId, 'POST', {}).then(function (res) {
button.disabled = false;
button.textContent = 'Find addresses';
if (res.success) {
renderAddresses(territoryId, res.data.addresses.map(function (a) {
return { full_address: a.full_address, lat: parseFloat(a.lat), lng: parseFloat(a.lng) };
}));
if (countEl) {
countEl.textContent = res.data.addresses.length;
}
} else {
alert('Could not find addresses: ' + JSON.stringify(res.errors));
}
});
});
JS);
$this->Leaflet->finalize();
?>

<div class="territory-list">
<h2>Saved territories</h2>
<?php if (!$territoriesData): ?>
<p class="territory-list-empty">No territories yet &mdash; draw one on the map above.</p>
<?php else: ?>
<table class="territory-table">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Addresses</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($territories as $territory): ?>
<tr>
<td><?= h($territory->name) ?></td>
<td><?= h($territory->created) ?></td>
<td>
<span class="js-address-count" data-territory-id="<?= $territory->id ?>">
<?= count($territory->addresses) ?>
</span>
</td>
<td>
<button type="button" class="button-link js-find-addresses" data-territory-id="<?= $territory->id ?>">Find addresses</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>

+ 1
- 0
app/templates/cell/.gitkeep Bestand weergeven

@@ -0,0 +1 @@


+ 15
- 0
app/templates/element/flash/default.php Bestand weergeven

@@ -0,0 +1,15 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
$class = 'message';
if (!empty($params['class'])) {
$class .= ' ' . $params['class'];
}
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="<?= h($class) ?>" onclick="this.classList.add('hidden');"><?= $message ?></div>

+ 11
- 0
app/templates/element/flash/error.php Bestand weergeven

@@ -0,0 +1,11 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="message error" onclick="this.classList.add('hidden');"><?= $message ?></div>

+ 11
- 0
app/templates/element/flash/info.php Bestand weergeven

@@ -0,0 +1,11 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="message" onclick="this.classList.add('hidden');"><?= $message ?></div>

+ 11
- 0
app/templates/element/flash/success.php Bestand weergeven

@@ -0,0 +1,11 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="message success" onclick="this.classList.add('hidden')"><?= $message ?></div>

+ 11
- 0
app/templates/element/flash/warning.php Bestand weergeven

@@ -0,0 +1,11 @@
<?php
/**
* @var \App\View\AppView $this
* @var array $params
* @var string $message
*/
if (!isset($params['escape']) || $params['escape'] !== false) {
$message = h($message);
}
?>
<div class="message warning" onclick="this.classList.add('hidden');"><?= $message ?></div>

+ 8
- 0
app/templates/element/footer.php Bestand weergeven

@@ -0,0 +1,8 @@
<?php
/**
* @var \App\View\AppView $this
*/
?>
<footer class="site-footer">
<p>&copy; <?= date('Y') ?> CakePHP App. All rights reserved.</p>
</footer>

+ 38
- 0
app/templates/element/header.php Bestand weergeven

@@ -0,0 +1,38 @@
<?php
/**
* @var \App\View\AppView $this
*/

use Cake\Core\Configure;

$apps = Configure::read('Apps', []);
?>
<header class="site-header">
<nav class="site-nav">
<div class="site-nav-brand">
<?= $this->Html->link(
'<span>Territory</span>',
'/',
['escapeTitle' => false]
) ?>
</div>

<ul class="site-nav-links">
<li><?= $this->Html->link('Home', '/') ?></li>
<li><?= $this->Html->link('Territories', ['controller' => 'Territories', 'action' => 'index']) ?></li>
</ul>

<?php if (!empty($apps)): ?>
<div class="site-app-switcher">
<button type="button" class="site-app-switcher-toggle" aria-haspopup="true" aria-expanded="false">
Apps <span class="caret">&#9662;</span>
</button>
<ul class="site-app-switcher-menu">
<?php foreach ($apps as $app): ?>
<li><?= $this->Html->link($app['label'], $app['url']) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</nav>
</header>

+ 22
- 0
app/templates/email/html/default.php Bestand weergeven

@@ -0,0 +1,22 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \Cake\View\View $this
* @var string $content
*/

$lines = explode("\n", $content);

foreach ($lines as $line) :
echo '<p> ' . $line . "</p>\n";
endforeach;

+ 18
- 0
app/templates/email/text/default.php Bestand weergeven

@@ -0,0 +1,18 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \Cake\View\View $this
* @var string $content
*/

echo $content;

+ 17
- 0
app/templates/layout/ajax.php Bestand weergeven

@@ -0,0 +1,17 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/

echo $this->fetch('content');

+ 52
- 0
app/templates/layout/default.php Bestand weergeven

@@ -0,0 +1,52 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/

$siteName = 'Territory';
?>
<!DOCTYPE html>
<html>
<head>
<?= $this->Html->charset() ?>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $siteName ?><?= $this->fetch('title') ? ' &middot; ' . $this->fetch('title') : '' ?></title>
<?= $this->Html->meta('icon') ?>
<?php if ($this->request->getAttribute('csrfToken')): ?>
<meta name="csrf-token" content="<?= h($this->request->getAttribute('csrfToken')) ?>">
<?php endif; ?>

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">

<?= $this->Html->css(['normalize.min', 'milligram.min', 'fonts', 'cake', 'app']) ?>
<?= $this->Html->script('https://code.jquery.com/jquery-3.7.1.min.js') ?>

<?= $this->fetch('meta') ?>
<?= $this->fetch('css') ?>
<?= $this->fetch('script') ?>
</head>
<body>
<?= $this->element('header') ?>
<main class="main">
<div class="container">
<?= $this->Flash->render() ?>
<?= $this->fetch('content') ?>
</div>
</main>
<?= $this->element('footer') ?>
<?= $this->Html->script('app') ?>
</body>
</html>

+ 25
- 0
app/templates/layout/email/html/default.php Bestand weergeven

@@ -0,0 +1,25 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/
?>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN">
<html>
<head>
<title><?= $this->fetch('title') ?></title>
</head>
<body>
<?= $this->fetch('content') ?>
</body>
</html>

+ 17
- 0
app/templates/layout/email/text/default.php Bestand weergeven

@@ -0,0 +1,17 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/

echo $this->fetch('content');

+ 39
- 0
app/templates/layout/error.php Bestand weergeven

@@ -0,0 +1,39 @@
<?php
/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 0.10.0
* @license https://opensource.org/licenses/mit-license.php MIT License
* @var \App\View\AppView $this
*/
?>
<!DOCTYPE html>
<html>
<head>
<?= $this->Html->charset() ?>
<title>
<?= $this->fetch('title') ?>
</title>
<?= $this->Html->meta('icon') ?>

<?= $this->Html->css(['normalize.min', 'milligram.min', 'fonts', 'cake']) ?>

<?= $this->fetch('meta') ?>
<?= $this->fetch('css') ?>
<?= $this->fetch('script') ?>
</head>
<body>
<div class="error-container">
<?= $this->Flash->render() ?>
<?= $this->fetch('content') ?>
<?= $this->Html->link(__('Back'), 'javascript:history.back()') ?>
</div>
</body>
</html>

+ 36
- 0
app/tests/Fixture/AddressesFixture.php Bestand weergeven

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);

namespace App\Test\Fixture;

use Cake\TestSuite\Fixture\TestFixture;

/**
* AddressesFixture
*/
class AddressesFixture extends TestFixture
{
/**
* Init method
*
* @return void
*/
public function init(): void
{
$this->records = [
[
'id' => 1,
'territory_id' => 1,
'osm_type' => 'Lorem ipsum dolor sit amet',
'osm_id' => 1,
'house_number' => 'Lorem ipsum dolor sit amet',
'street' => 'Lorem ipsum dolor sit amet',
'full_address' => 'Lorem ipsum dolor sit amet',
'lat' => 1.5,
'lng' => 1.5,
'created' => '2026-07-27 21:06:43',
],
];
parent::init();
}
}

+ 31
- 0
app/tests/Fixture/TerritoriesFixture.php Bestand weergeven

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);

namespace App\Test\Fixture;

use Cake\TestSuite\Fixture\TestFixture;

/**
* TerritoriesFixture
*/
class TerritoriesFixture extends TestFixture
{
/**
* Init method
*
* @return void
*/
public function init(): void
{
$this->records = [
[
'id' => 1,
'name' => 'Lorem ipsum dolor sit amet',
'polygon' => '',
'created' => '2026-07-27 20:48:37',
'modified' => '2026-07-27 20:48:37',
],
];
parent::init();
}
}

+ 88
- 0
app/tests/TestCase/ApplicationTest.php Bestand weergeven

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.3.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Test\TestCase;

use App\Application;
use App\Middleware\HostHeaderMiddleware;
use Cake\Core\Configure;
use Cake\Error\Middleware\ErrorHandlerMiddleware;
use Cake\Http\MiddlewareQueue;
use Cake\Routing\Middleware\AssetMiddleware;
use Cake\Routing\Middleware\RoutingMiddleware;
use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;

/**
* ApplicationTest class
*/
class ApplicationTest extends TestCase
{
use IntegrationTestTrait;

/**
* Test bootstrap in production.
*
* @return void
*/
public function testBootstrap()
{
Configure::write('debug', false);
$app = new Application(dirname(__DIR__, 2) . '/config');
$app->bootstrap();
$plugins = $app->getPlugins();

$this->assertTrue($plugins->has('Bake'), 'plugins has Bake?');
$this->assertFalse($plugins->has('DebugKit'), 'plugins has DebugKit?');
$this->assertTrue($plugins->has('Migrations'), 'plugins has Migrations?');
}

/**
* Test bootstrap add DebugKit plugin in debug mode.
*
* @return void
*/
public function testBootstrapInDebug()
{
Configure::write('debug', true);
$app = new Application(dirname(__DIR__, 2) . '/config');
$app->bootstrap();
$plugins = $app->getPlugins();

$this->assertTrue($plugins->has('DebugKit'), 'plugins has DebugKit?');
}

/**
* testMiddleware
*
* @return void
*/
public function testMiddleware()
{
$app = new Application(dirname(__DIR__, 2) . '/config');
$middleware = new MiddlewareQueue();

$middleware = $app->middleware($middleware);

$this->assertInstanceOf(ErrorHandlerMiddleware::class, $middleware->current());
$middleware->seek(1);
$this->assertInstanceOf(HostHeaderMiddleware::class, $middleware->current());
$middleware->seek(2);
$this->assertInstanceOf(AssetMiddleware::class, $middleware->current());
$middleware->seek(3);
$this->assertInstanceOf(RoutingMiddleware::class, $middleware->current());
}
}

+ 0
- 0
app/tests/TestCase/Controller/Component/.gitkeep Bestand weergeven


+ 113
- 0
app/tests/TestCase/Controller/PagesControllerTest.php Bestand weergeven

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 1.2.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/
namespace App\Test\TestCase\Controller;

use Cake\Core\Configure;
use Cake\TestSuite\Constraint\Response\StatusCode;
use Cake\TestSuite\IntegrationTestTrait;
use Cake\TestSuite\TestCase;

/**
* PagesControllerTest class
*/
class PagesControllerTest extends TestCase
{
use IntegrationTestTrait;

/**
* testDisplay method
*
* @return void
*/
public function testDisplay()
{
Configure::write('debug', true);
$this->get('/pages/home');
$this->assertResponseOk();
$this->assertResponseContains('CakePHP');
$this->assertResponseContains('<html>');
}

/**
* Test that missing template renders 404 page in production
*
* @return void
*/
public function testMissingTemplate()
{
Configure::write('debug', false);
$this->get('/pages/not_existing');

$this->assertResponseError();
$this->assertResponseContains('Error');
}

/**
* Test that missing template in debug mode renders missing_template error page
*
* @return void
*/
public function testMissingTemplateInDebug()
{
Configure::write('debug', true);
$this->get('/pages/not_existing');

$this->assertResponseFailure();
$this->assertResponseContains('Missing Template');
$this->assertResponseContains('stack-frames');
$this->assertResponseContains('not_existing.php');
}

/**
* Test directory traversal protection
*
* @return void
*/
public function testDirectoryTraversalProtection()
{
$this->get('/pages/../Layout/ajax');
$this->assertResponseCode(403);
$this->assertResponseContains('Forbidden');
}

/**
* Test that CSRF protection is applied to page rendering.
*
* @return void
*/
public function testCsrfAppliedError()
{
$this->post('/pages/home', ['hello' => 'world']);

$this->assertResponseCode(403);
$this->assertResponseContains('CSRF');
}

/**
* Test that CSRF protection is applied to page rendering.
*
* @return void
*/
public function testCsrfAppliedOk()
{
$this->enableCsrfToken();
$this->post('/pages/home', ['hello' => 'world']);

$this->assertThat(403, $this->logicalNot(new StatusCode($this->_response)));
$this->assertResponseNotContains('CSRF');
}
}

+ 0
- 0
app/tests/TestCase/Model/Behavior/.gitkeep Bestand weergeven


+ 76
- 0
app/tests/TestCase/Model/Table/AddressesTableTest.php Bestand weergeven

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);

namespace App\Test\TestCase\Model\Table;

use App\Model\Table\AddressesTable;
use Cake\TestSuite\TestCase;

/**
* App\Model\Table\AddressesTable Test Case
*/
class AddressesTableTest extends TestCase
{
/**
* Test subject
*
* @var \App\Model\Table\AddressesTable
*/
protected $Addresses;

/**
* Fixtures
*
* @var array<string>
*/
protected array $fixtures = [
'app.Addresses',
'app.Territories',
];

/**
* setUp method
*
* @return void
*/
protected function setUp(): void
{
parent::setUp();
$config = $this->getTableLocator()->exists('Addresses') ? [] : ['className' => AddressesTable::class];
$this->Addresses = $this->getTableLocator()->get('Addresses', $config);
}

/**
* tearDown method
*
* @return void
*/
protected function tearDown(): void
{
unset($this->Addresses);

parent::tearDown();
}

/**
* Test validationDefault method
*
* @return void
* @link \App\Model\Table\AddressesTable::validationDefault()
*/
public function testValidationDefault(): void
{
$this->markTestIncomplete('Not implemented yet.');
}

/**
* Test buildRules method
*
* @return void
* @link \App\Model\Table\AddressesTable::buildRules()
*/
public function testBuildRules(): void
{
$this->markTestIncomplete('Not implemented yet.');
}
}

+ 64
- 0
app/tests/TestCase/Model/Table/TerritoriesTableTest.php Bestand weergeven

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);

namespace App\Test\TestCase\Model\Table;

use App\Model\Table\TerritoriesTable;
use Cake\TestSuite\TestCase;

/**
* App\Model\Table\TerritoriesTable Test Case
*/
class TerritoriesTableTest extends TestCase
{
/**
* Test subject
*
* @var \App\Model\Table\TerritoriesTable
*/
protected $Territories;

/**
* Fixtures
*
* @var array<string>
*/
protected array $fixtures = [
'app.Territories',
];

/**
* setUp method
*
* @return void
*/
protected function setUp(): void
{
parent::setUp();
$config = $this->getTableLocator()->exists('Territories') ? [] : ['className' => TerritoriesTable::class];
$this->Territories = $this->getTableLocator()->get('Territories', $config);
}

/**
* tearDown method
*
* @return void
*/
protected function tearDown(): void
{
unset($this->Territories);

parent::tearDown();
}

/**
* Test validationDefault method
*
* @return void
* @link \App\Model\Table\TerritoriesTable::validationDefault()
*/
public function testValidationDefault(): void
{
$this->markTestIncomplete('Not implemented yet.');
}
}

+ 0
- 0
app/tests/TestCase/View/Helper/.gitkeep Bestand weergeven


+ 60
- 0
app/tests/bootstrap.php Bestand weergeven

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 3.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/

use Cake\Chronos\Chronos;
use Cake\Core\Configure;
use Cake\TestSuite\ConnectionHelper;
use Migrations\TestSuite\Migrator;

/**
* Test runner bootstrap.
*
* Add additional configuration/setup your application needs when running
* unit tests in this file.
*/
require dirname(__DIR__) . '/vendor/autoload.php';

require dirname(__DIR__) . '/config/bootstrap.php';

if (empty($_SERVER['HTTP_HOST']) && !Configure::read('App.fullBaseUrl')) {
Configure::write('App.fullBaseUrl', 'http://localhost');
}

// Fixate now to avoid one-second-leap-issues
Chronos::setTestNow(Chronos::now());

// Fixate sessionid early on, as php7.2+
// does not allow the sessionid to be set after stdout
// has been written to.
session_id('cli');

// Connection aliasing needs to happen before migrations are run.
// Otherwise, table objects inside migrations would use the default datasource
ConnectionHelper::addTestAliases();

// Use migrations to build test database schema.
//
// Will rebuild the database if the migration state differs
// from the migration history in files.
//
// If you are not using CakePHP's migrations you can
// hook into your migration tool of choice here or
// load schema from a SQL dump file with
// use Cake\TestSuite\Fixture\SchemaLoader;
// (new SchemaLoader())->loadSqlFiles('./tests/schema.sql', 'test');

(new Migrator())->run();

+ 4
- 0
app/tests/schema.sql Bestand weergeven

@@ -0,0 +1,4 @@
-- Test database schema.
--
-- If you are not using CakePHP migrations you can put
-- your application's schema in this file and use it in tests.

+ 5
- 0
app/webroot/.htaccess Bestand weergeven

@@ -0,0 +1,5 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

+ 262
- 0
app/webroot/css/app.css Bestand weergeven

@@ -0,0 +1,262 @@
:root {
--color-bg: #f4f6fb;
--color-surface: #ffffff;
--color-text: #1f2430;
--color-text-muted: #6b7280;
--color-border: #e3e7ef;
--color-primary: #2f5fe0;
--color-primary-dark: #1a1a2e;
--color-accent: #4ea1ff;
--radius: 0.6rem;
--shadow-sm: 0 0.1rem 0.3rem rgba(20, 24, 40, 0.06);
--shadow-md: 0 0.6rem 1.8rem rgba(20, 24, 40, 0.08);
}

body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--color-bg);
color: var(--color-text);
}

a {
color: var(--color-primary);
}

.main {
padding: 3rem 0 5rem;
}

.main .container {
max-width: 112rem;
}

/* Header / nav */

.site-header {
background: var(--color-primary-dark);
box-shadow: var(--shadow-md);
position: sticky;
top: 0;
z-index: 20;
}

.site-nav {
display: flex;
align-items: center;
gap: 2rem;
max-width: 112rem;
margin: 0 auto;
padding: 1.2rem 2rem;
}

.site-nav-brand a {
color: #fff;
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 0.02rem;
text-decoration: none;
}

.site-nav-brand span {
color: var(--color-accent);
}

.site-nav-links {
display: flex;
gap: 1.5rem;
margin: 0;
padding: 0;
list-style: none;
flex: 1;
}

.site-nav-links a {
color: #c3c8da;
text-decoration: none;
font-size: 1.4rem;
font-weight: 500;
transition: color 0.15s ease;
}

.site-nav-links a:hover {
color: #fff;
}

.site-app-switcher {
position: relative;
}

.site-app-switcher-toggle {
background: transparent;
border: 1px solid var(--color-accent);
color: var(--color-accent);
border-radius: var(--radius);
padding: 0.6rem 1.4rem;
font-size: 1.3rem;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}

.site-app-switcher-toggle:hover {
background: var(--color-accent);
color: var(--color-primary-dark);
}

.site-app-switcher-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
background: var(--color-surface);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
list-style: none;
margin: 0;
padding: 0.5rem 0;
min-width: 16rem;
z-index: 10;
}

.site-app-switcher-menu li a {
display: block;
padding: 0.8rem 1.5rem;
color: var(--color-text);
text-decoration: none;
font-size: 1.4rem;
}

.site-app-switcher-menu li a:hover {
background: var(--color-bg);
}

.site-app-switcher.is-open .site-app-switcher-menu {
display: block;
}

/* Page content */

.content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 3rem;
}

.page-header {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
}

.page-header h1 {
margin-bottom: 0.5rem;
}

.page-header p {
color: var(--color-text-muted);
margin: 0;
}

.content h1 {
font-weight: 700;
letter-spacing: -0.01rem;
}

/* Leaflet map */

.leaflet-map {
border-radius: var(--radius);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
overflow: hidden;
}

/* Territories list */

.territory-list {
margin-top: 2.5rem;
}

.territory-list h2 {
font-size: 1.8rem;
margin-bottom: 1rem;
}

.territory-list-empty {
color: var(--color-text-muted);
}

.territory-table {
width: 100%;
border-collapse: collapse;
font-size: 1.4rem;
}

.territory-table th,
.territory-table td {
text-align: left;
padding: 1rem 1.2rem;
border-bottom: 1px solid var(--color-border);
}

.territory-table th {
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
font-size: 1.1rem;
letter-spacing: 0.03rem;
}

.territory-table tbody tr:hover {
background: var(--color-bg);
}

.button-link {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 0.6rem 1.4rem;
font-size: 1.3rem;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease;
}

.button-link:hover {
background: var(--color-primary-dark);
}

.button-link:disabled {
background: var(--color-text-muted);
cursor: default;
}

/* Address markers */

.leaflet-div-icon.address-marker-icon {
display: flex;
align-items: center;
justify-content: center;
background: #e0442f;
color: #fff;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: var(--shadow-sm);
font-size: 1.3rem;
line-height: 1;
}

/* Footer */

.site-footer {
max-width: 112rem;
margin: 0 auto;
padding: 2.5rem 2rem;
text-align: center;
color: var(--color-text-muted);
font-size: 1.3rem;
border-top: 1px solid var(--color-border);
}

+ 301
- 0
app/webroot/css/cake.css Bestand weergeven

@@ -0,0 +1,301 @@
/* Milligram overrides */
:root {
/* The following are official CakePHP colors */
--color-cakephp-red: #d33c43;
--color-cakephp-gray: #404041;
--color-cakephp-blue: #2f85ae;
--color-cakephp-lightblue: #34bdd7;

/* These are additional colors */
--color-lightgray: #606c76;
--color-white: #fff;

--color-main-bg: #f5f7fa;
--color-links: var(--color-cakephp-blue);
--color-links-active: #2a6496;
--color-headings: #363637;

--color-message-success-bg: #e3fcec;
--color-message-success-text: #1f9d55;
--color-message-success-border: #51d88a;

--color-message-warning-bg: #fffabc;
--color-message-warning-text: #8d7b00;
--color-message-warning-border: #d3b800;

--color-message-error-bg: #fcebea;
--color-message-error-text: #cc1f1a;
--color-message-error-border: #ef5753;

--color-message-info-bg: #eff8ff;
--color-message-info-text: #2779bd;
--color-message-info-border: #6cb2eb;
}

.button, button, input[type='button'], input[type='reset'], input[type='submit'] {
background-color: var(--color-cakephp-red);
border-color: var(--color-cakephp-red);
}

body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 400;
background: var(--color-main-bg);
}

.top-nav-links,
.side-nav,
h1, h2, h3, h4, h5, h6 {
font-family: "Raleway", sans-serif;
}

h1, h2, h3, h4, h5, h6 {
font-weight: 400;
color: var(--color-headings);
}

a {
color: var(--color-links);
transition: color 0.2s linear;
}

a:hover,
a:focus,
a:active {
color: var(--color-links-active);
transition: color 0.2s ease-out;
}

.side-nav a,
.top-nav-links a,
th a,
.actions a {
color: var(--color-lightgray);
}

.side-nav a:hover,
.side-nav a:focus,
.actions a:hover,
.actions a:focus {
color: var(--color-links-active);
}

/* Utility */
.table-responsive {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}

/* Main */
.content {
padding: 2rem;
background: var(--color-white);
border-radius: 0.4rem;
/* Thanks Stripe */
box-shadow: 0 7px 14px 0 rgba(60, 66, 87, 0.1),
0 3px 6px 0 rgba(0, 0, 0, 0.07);
}
.content form {
margin: 0;
}
.actions a {
font-weight: bold;
padding: 0 0.4rem;
}
.actions a:first-child {
padding-left: 0;
}
th {
white-space: nowrap;
}

/* Nav bar */
.top-nav {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 112rem;
padding: 2rem;
margin: 0 auto;
}
.top-nav-title a {
font-size: 2.4rem;
color: var(--color-cakephp-red);
}
.top-nav-title span {
color: var(--color-cakephp-gray);
}
.top-nav-links a {
margin: 0 0.5rem;
}
.top-nav-title a,
.top-nav-links a {
font-weight: bold;
}
.side-nav-item {
display: block;
padding: 0.5rem 0;
}

/* View action */
.view.content .text {
margin-top: 1.2rem;
}
.related {
margin-top: 2rem;
}

/* Flash messages */
.message {
padding: .5rem 1rem;
background: var(--color-message-info-bg);
color: var(--color-message-info-text);
border-color: var(--color-message-info-border);
border-width: 1px;
border-style: solid;
border-radius: 4px;
margin-bottom: 1rem;
cursor: pointer;
}
.message.hidden {
display: none;
}
.message.success {
background: var(--color-message-success-bg);
color: var(--color-message-success-text);
border-color: var(--color-message-success-border);
}
.message.warning {
background: var(--color-message-warning-bg);
color: var(--color-message-warning-text);
border-color: var(--color-message-warning-border);
}
.message.error {
background: var(--color-message-error-bg);
color: var(--color-message-error-text);
border-color: var(--color-message-error-border);
}

/* Forms */
.input {
margin-bottom: 1.5rem;
}
.input input,
.input select,
.input textarea {
margin-bottom: 0;
}
.input label:has(input[type='checkbox']),
.input label:has(input[type='radio']) {
display: flex;
align-items: center;
}
.input label:has(~ label),
.input label:has(input[type='radio']) {
margin-bottom: 0;
}
.input label > input[type='checkbox'],
.input label > input[type='radio'] {
margin-right: 1.0rem;
}
input[type='color'] {
max-width: 4rem;
padding: 0.3rem .5rem 0.3rem;
}
.error-message {
color: var(--color-message-error-text);
}

/* Paginator */
.paginator {
text-align: right;
}
.paginator p {
margin-bottom: 0;
}
.pagination {
display: flex;
justify-content: center;
list-style: none;
margin: 0 0 1rem 0;
padding: 0;
}
.pagination li {
display: inline-block;
margin: 0.25em;
text-align: center;
}
.pagination a {
color: var(--color-cakephp-blue);
display: inline-block;
font-size: 1.25rem;
line-height: 3rem;
min-width: 3rem;
padding: 0;
position: relative;
text-decoration: none;
transition: background .3s,color .3s;
}
.pagination li.active a,
.pagination a:hover {
text-decoration: underline;
}
.pagination .disabled a {
cursor: not-allowed;
color: var(--color-lightgray);
text-decoration: none;
}
.first a,
.prev a,
.next a,
.last a {
padding: 0 .75rem;
}
.disabled a:hover {
background: initial;
color: initial;
}
.asc:after {
content: " \2193";
}
.desc:after {
content: " \2191";
}

/* Error in non debug mode */
.error-container {
align-items: center;
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
}

@media screen and (max-width: 640px) {
/* Fix milligram not having a responsive column system */
.row .column[class*='column-'] {
flex: 0 0 100%;
max-width: 100%
}
.top-nav {
margin: 0 auto;
}
.side-nav {
margin-bottom: 1rem;
}
.heading {
margin-bottom: 1rem;
}
.side-nav-item {
display: inline;
margin: 0 1.5rem 0 0;
}
.asc:after {
content: " \2192";
}
.desc:after {
content: " \2190";
}
}

+ 80
- 0
app/webroot/css/fonts.css Bestand weergeven

@@ -0,0 +1,80 @@
/* cyrillic-ext */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-latin-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
src: url('../font/raleway-400-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-cyrillic-ext.woff2') format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-cyrillic.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* vietnamese */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-latin-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
src: url('../font/raleway-700-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

+ 75
- 0
app/webroot/css/home.css Bestand weergeven

@@ -0,0 +1,75 @@
/* Home page styles */
@font-face {
font-family: 'cakefont';
src: url('../font/cakedingbats-webfont.eot');
src: url('../font/cakedingbats-webfont.eot?#iefix') format('embedded-opentype'),
url('../font/cakedingbats-webfont.woff2') format('woff2'),
url('../font/cakedingbats-webfont.woff') format('woff'),
url('../font/cakedingbats-webfont.ttf') format('truetype'),
url('../font/cakedingbats-webfont.svg#cake_dingbatsregular') format('svg');
font-weight: normal;
font-style: normal;
}
body {
padding: 60px 0;
}
header {
margin-bottom: 60px;
}
img {
margin-bottom: 30px;
}
h1 {
font-weight: bold;
}
ul {
list-style-type: none;
margin: 0 0 30px 0;
padding-left: 25px;
}
a {
color: #0071BC;
text-decoration: underline;
}
hr {
border-bottom: 1px solid #e7e7e7;
border-top: 0;
margin-bottom: 35px;
}

.text-center {
text-align: center;
}
.links a {
margin-right: 10px;
}
.release-name {
color: #D33C43;
font-weight: 400;
font-style: italic;
}
.bullet:before {
font-family: 'cakefont', sans-serif;
font-size: 18px;
display: inline-block;
margin-left: -1.3em;
width: 1.2em;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
vertical-align: -1px;
}
.success:before {
color: #88c671;
content: "\0056";
}
.problem:before {
color: #d33d44;
content: "\0057";
}
.cake-error {
padding: 10px;
margin: 10px 0;
}
#url-rewriting-warning {
display: none;
}

+ 9
- 0
app/webroot/css/milligram.min.css
Diff onderdrukt omdat het te groot bestand
Bestand weergeven


+ 8
- 0
app/webroot/css/normalize.min.css Bestand weergeven

@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using clean-css v4.2.1.
* Original file: /npm/normalize.css@8.0.1/normalize.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}

BIN
app/webroot/favicon.ico Bestand weergeven

Before After

+ 51
- 0
app/webroot/font/Raleway-License.txt Bestand weergeven

@@ -0,0 +1,51 @@
License for 'Raleway'
SIL Open Font License
Copyright (c) 2010, Matt McInerney (matt@pixelspread.com),
Copyright (c) 2011, Pablo Impallari (www.impallari.com|impallari@gmail.com),
Copyright (c) 2011, Rodrigo Fuenzalida (www.rfuenzalida.com|hello@rfuenzalida.com), with Reserved Font Name Raleway

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL

—————————————————————————————-
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
—————————————————————————————-

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.

The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.

DEFINITIONS
“Font Software” refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.

“Reserved Font Name” refers to any names specified as such after the copyright statement(s).

“Original Version” refers to the collection of Font Software components as distributed by the Copyright Holder(s).

“Modified Version” refers to any derivative made by adding to, deleting, or substituting—in part or in whole—any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.

“Author” refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.

PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.

5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
app/webroot/font/cakedingbats-webfont.eot Bestand weergeven


+ 78
- 0
app/webroot/font/cakedingbats-webfont.svg
Diff onderdrukt omdat het te groot bestand
Bestand weergeven


BIN
app/webroot/font/cakedingbats-webfont.ttf Bestand weergeven


BIN
app/webroot/font/cakedingbats-webfont.woff Bestand weergeven


BIN
app/webroot/font/cakedingbats-webfont.woff2 Bestand weergeven


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

Laden…
Annuleren
Opslaan

Powered by TurnKey Linux.