Compare commits

..
16 Commits
Author SHA1 Message Date
Nico Schröder 9895fc36db fix: make project template inheritance granular and safe
CI/CD / test_and_check_app (push) Failing after 3m8s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Failing after 3m8s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-08 10:25:52 +00:00
Twyco fed6a282a4 Fix KAN-3 project template flows 2026-09-08 10:25:52 +00:00
Koda db8855a056 Move remaining project metadata ownership to templates 2026-09-08 10:25:52 +00:00
Koda 04b0d5e316 Add editable project template flow 2026-09-08 10:25:52 +00:00
Koda 49e887753c Add template switch and reset flows for existing projects 2026-09-08 10:25:52 +00:00
Koda b8a1d65122 Add template context to API and MCP project payloads 2026-09-08 10:25:52 +00:00
Koda 831014f7fe Show template inheritance overrides 2026-09-08 10:25:52 +00:00
Koda fef82cb88f Make projects depend on templates 2026-09-08 10:25:52 +00:00
Koda 40f9b473fc Add reusable project templates 2026-09-08 10:25:52 +00:00
Koda 5abfae2736 Add project duplication flow 2026-09-08 10:25:52 +00:00
Twyco 18d19fa473 create admin user fix
CI/CD / test_and_check_app (push) Successful in 1m9s
CI/CD / Publish immutable image (push) Successful in 1m12s
2026-08-23 14:49:26 +02:00
Twyco 7331c122b5 Update Dockerfile
CI/CD / test_and_check_app (push) Successful in 3m53s
CI/CD / Publish immutable image (push) Successful in 2m32s
2026-08-22 09:27:06 +00:00
Twyco a731f9ea10 fix Kubernetes runtime deployment
CI/CD / test_and_check_app (push) Failing after 2m0s
CI/CD / Publish immutable image (push) Skipped
2026-08-22 09:40:44 +02:00
Twyco afe99ea6d4 feat: add containerized deployment workflow
CI/CD / test_and_check_app (push) Failing after 7m26s
CI/CD / Publish immutable image (push) Skipped
2026-08-22 08:57:18 +02:00
Twyco e8ee23371b updated git action 2026-08-21 18:35:43 +02:00
Twyco 96df45456e updated laravel 2026-08-21 18:35:13 +02:00
31 changed files with 1906 additions and 802 deletions
+96
View File
@@ -0,0 +1,96 @@
---
name: mcp-development
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
license: MIT
metadata:
author: laravel
---
# MCP Development
## Documentation
Use `search-docs` for detailed Laravel MCP patterns and documentation.
## Basic Usage
Register MCP servers in `routes/ai.php`:
<!-- Register MCP Server -->
```php
use Laravel\Mcp\Facades\Mcp;
Mcp::web();
```
### Creating MCP Primitives
Create MCP tools, resources, prompts, and servers using artisan commands:
```bash
php artisan make:mcp-tool ToolName # Create a tool
php artisan make:mcp-resource ResourceName # Create a resource
php artisan make:mcp-prompt PromptName # Create a prompt
php artisan make:mcp-server ServerName # Create a server
```
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
### Tools
<!-- MCP Tool Example -->
```php
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Request;
use Laravel\Mcp\Server\Response;
class MyTool extends Tool
{
public function handle(Request $request): Response
{
return new Response(['result' => 'success']);
}
}
```
### Registering Primitives in a Server
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
<!-- Register Primitives in MCP Server -->
```php
use Laravel\Mcp\Server;
class AppServer extends Server
{
protected array $tools = [
\App\Mcp\Tools\MyTool::class,
];
protected array $resources = [
\App\Mcp\Resources\MyResource::class,
];
protected array $prompts = [
\App\Mcp\Prompts\MyPrompt::class,
];
}
```
## Verification
1. Check `routes/ai.php` for proper registration
2. Test tool via MCP client
## Common Pitfalls
- Running `mcp:start` command (it hangs waiting for input)
- Using HTTPS locally with Node-based MCP clients
- Not using `search-docs` for the latest MCP documentation
- Not registering MCP server routes in `routes/ai.php`
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
+6 -7
View File
@@ -25,7 +25,6 @@
**/compose.*.y*ml **/compose.*.y*ml
!**/composer.json !**/composer.json
!**/composer.lock !**/composer.lock
**/Dockerfile*
**/node_modules **/node_modules
**/npm-debug.log **/npm-debug.log
**/obj **/obj
@@ -41,9 +40,9 @@ vendor
LICENSE LICENSE
README.md README.md
# Ignore development-related files and directories # Runtime-generated data must not become part of an image layer.
# For development environment, we will use mount bind to mount the source code to the container storage/framework/cache/data/*
# So we don't need to copy the source code to the container for development storage/framework/sessions/*
tests/ storage/framework/testing/*
phpunit.xml storage/framework/views/*
phpunit.xml.dist storage/logs/*
+17 -1
View File
@@ -10,7 +10,7 @@ APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
LOG_STACK=daily LOG_STACK=stderr
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it) # Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
LOG_DISCORD_WEBHOOK_URL= LOG_DISCORD_WEBHOOK_URL=
@@ -38,6 +38,22 @@ MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=no-reply@example.com MAIL_FROM_ADDRESS=no-reply@example.com
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
CACHE_STORE=database
QUEUE_CONNECTION=database
SESSION_DRIVER=database
FILESYSTEM_DISK=public FILESYSTEM_DISK=public
AVATAR_FILESYSTEM_DISK=public
AVATAR_URLS_TEMPORARY=false
AVATAR_URL_EXPIRATION_MINUTES=15
# Production: set AVATAR_FILESYSTEM_DISK=s3 and inject these as runtime secrets.
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=eu-central-1
AWS_BUCKET=
AWS_URL=
AWS_ENDPOINT=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
@@ -0,0 +1,18 @@
name: test_and_check_app
description: Build the test image and run all application checks
runs:
using: composite
steps:
- name: Test and check app
shell: bash
run: |
set -Eeuo pipefail
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
+130
View File
@@ -0,0 +1,130 @@
name: CI/CD
on:
push:
pull_request:
permissions:
contents: read
env:
REGISTRY: git.twyco.de
jobs:
test_and_check_app:
name: test_and_check_app
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Run required application checks
uses: ./.github/tests/test_and_check_app
publish:
name: Publish immutable image
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
needs:
- test_and_check_app
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- name: Validate ref and calculate image name
id: image
shell: bash
run: |
set -Eeuo pipefail
image_path="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
image="${REGISTRY}/${image_path}"
case "${GITHUB_REF}" in
refs/heads/main)
printf -v run_attempt '%03d' "${GITHUB_RUN_ATTEMPT}"
tag="main-${GITHUB_RUN_NUMBER}-${run_attempt}-${GITHUB_SHA:0:12}"
environment="staging"
;;
refs/tags/*)
tag="${GITHUB_REF#refs/tags/}"
if [[ ! "${tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "Production tags must match vMAJOR.MINOR.PATCH." >&2
exit 1
fi
git fetch --no-tags origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
echo "Production tag must point to a commit contained in main." >&2
exit 1
fi
environment="production"
;;
*)
echo "Unsupported ref: ${GITHUB_REF}" >&2
exit 1
;;
esac
{
echo "image=${image}"
echo "tag=${tag}"
echo "environment=${environment}"
} >> "${GITHUB_OUTPUT}"
- name: Log in to private registry
shell: bash
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -Eeuo pipefail
if [[ -z "${REGISTRY_USERNAME}" || -z "${REGISTRY_TOKEN}" ]]; then
echo "Registry credentials are missing." >&2
exit 1
fi
printf '%s' "${REGISTRY_TOKEN}" |
docker login \
--username "${REGISTRY_USERNAME}" \
--password-stdin \
"${REGISTRY}"
- name: Build and publish runtime image
shell: bash
env:
IMAGE: ${{ steps.image.outputs.image }}
IMAGE_TAG: ${{ steps.image.outputs.tag }}
DEPLOYMENT_ENVIRONMENT: ${{ steps.image.outputs.environment }}
run: |
set -Eeuo pipefail
if [[ "${DEPLOYMENT_ENVIRONMENT}" == "production" ]] &&
docker buildx imagetools inspect "${IMAGE}:${IMAGE_TAG}" >/dev/null 2>&1; then
echo "Production image ${IMAGE}:${IMAGE_TAG} already exists and will not be overwritten." >&2
exit 1
fi
docker buildx build \
--file Dockerfile \
--target runtime \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
--label "de.twyco.environment=${DEPLOYMENT_ENVIRONMENT}" \
--tag "${IMAGE}:${IMAGE_TAG}" \
--push \
.
- name: Remove registry credentials
if: ${{ always() }}
shell: bash
run: docker logout "${REGISTRY}" >/dev/null 2>&1 || true
-39
View File
@@ -1,39 +0,0 @@
name: linter
on:
push:
branches:
- main
- staging
pull_request:
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: composer lint
- name: Lint Frontend
run: npm run lint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v7
# with:
# commit_message: fix code style
# commit_options: '--no-verify'
-49
View File
@@ -1,49 +0,0 @@
name: tests
on:
push:
branches:
- main
- staging
pull_request:
jobs:
ci:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['8.4']
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install Node Dependencies
run: npm i
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Build Assets
run: npm run build
- name: Tests
run: ./vendor/bin/phpunit
+2 -2
View File
@@ -13,10 +13,10 @@ This application is a Laravel application and its main Laravel ecosystems packag
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2 - inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
- laravel/fortify (FORTIFY) - v1 - laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13 - laravel/framework (LARAVEL) - v13
- laravel/mcp (MCP) - v0
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/wayfinder (WAYFINDER) - v0 - laravel/wayfinder (WAYFINDER) - v0
- laravel/boost (BOOST) - v2 - laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1 - laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1 - laravel/sail (SAIL) - v1
@@ -34,7 +34,7 @@ This project has domain-specific skills available. You MUST activate the relevan
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features. - `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. - `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `project-route-structure` — Always apply in this project when creating, moving, or refactoring Laravel route definitions in routes/**/*.php; changing route prefixes, names, middleware, scoped bindings, or route files; or adding frontend-facing endpoints that Wayfinder generates. Documents the KanbanGit route-file layout and grouping conventions. - `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task - `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation. - `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS. - `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
+109
View File
@@ -0,0 +1,109 @@
ARG COMPOSER_IMAGE=composer:2.9.5@sha256:698d3801b2a622ace460c4743c781282fcbcb733a4cbf8b31c44731e846585e8
ARG NODE_IMAGE=node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
ARG PHP_IMAGE=php:8.4-fpm-bookworm@sha256:c5fb7a0c02f4efe280691910c8b734995fa83598cdcf3115ef5dcb2e4617681c
FROM ${COMPOSER_IMAGE} AS composer_binary
FROM ${NODE_IMAGE} AS node_binary
FROM ${PHP_IMAGE} AS php_base
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gettext-base \
git \
libfcgi-bin \
libfreetype6-dev \
libicu-dev \
libjpeg62-turbo-dev \
libmagickwand-dev \
libonig-dev \
libpng-dev \
libpq-dev \
libsqlite3-dev \
libssl-dev \
libxml2-dev \
libzip-dev \
nginx \
procps \
unzip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" \
bcmath \
gd \
intl \
opcache \
pcntl \
pdo_mysql \
pdo_pgsql \
pdo_sqlite \
pgsql \
soap \
zip \
&& pecl install imagick \
&& docker-php-ext-enable imagick \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
COPY --from=composer_binary /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www
FROM php_base AS build
COPY --from=node_binary /usr/local/ /usr/local/
COPY . /var/www
RUN composer install --prefer-dist --no-interaction --no-progress \
&& npm ci \
&& npm run build
FROM build AS test
RUN touch .env \
&& composer audit --locked --no-interaction \
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
FROM build AS production_builder
RUN composer install \
--classmap-authoritative \
--no-dev \
--no-interaction \
--no-progress \
--prefer-dist \
&& rm -rf node_modules tests phpunit.xml
FROM php_base AS runtime
COPY docker/entrypoint.sh /usr/local/bin/kanbangit-entrypoint
COPY docker/nginx/nginx.conf.template /etc/nginx/nginx.conf.template
COPY docker/php-fpm-healthcheck.sh /usr/local/bin/php-fpm-healthcheck
COPY --from=production_builder --chown=www-data:www-data /var/www /var/www
RUN chmod +x /usr/local/bin/kanbangit-entrypoint /usr/local/bin/php-fpm-healthcheck \
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
&& printf '%s\n' \
'[global]' \
'daemonize = no' \
'' \
'[www]' \
'pm.status_path = /status' \
> /usr/local/etc/php-fpm.d/zz-kanbangit.conf \
&& mkdir -p \
/var/www/bootstrap/cache \
/var/www/storage/app/public \
/var/www/storage/framework/cache \
/var/www/storage/framework/sessions \
/var/www/storage/framework/testing \
/var/www/storage/framework/views \
/var/www/storage/logs \
&& ln -s ../storage/app/public /var/www/public/storage \
&& chown -R www-data:www-data /var/www/bootstrap/cache /var/www/storage
USER www-data
EXPOSE 8080 9000
ENTRYPOINT ["kanbangit-entrypoint"]
CMD ["php-fpm"]
@@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource;
use App\Http\Resources\TicketReferenceResource; use App\Http\Resources\TicketReferenceResource;
use App\Http\Resources\TicketStatusResource; use App\Http\Resources\TicketStatusResource;
use App\Http\Resources\TicketTypeResource; use App\Http\Resources\TicketTypeResource;
use App\Models\Ticket;
use App\Http\Resources\UserResource; use App\Http\Resources\UserResource;
use App\Models\Project; use App\Models\Project;
use App\Models\Ticket;
use App\Models\User; use App\Models\User;
class BuildProjectTicketOptionsPayload class BuildProjectTicketOptionsPayload
+11 -3
View File
@@ -30,9 +30,10 @@ class UpdateUserAvatar
$color = $this->colorFor($user); $color = $this->colorFor($user);
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid()); $path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
$stored = Storage::disk('public')->put( $stored = Storage::disk($this->avatarDisk())->put(
$path, $path,
$this->svg($this->initials($user->name), $color), $this->svg($this->initials($user->name), $color),
['ContentType' => 'image/svg+xml'],
); );
if (! $stored) { if (! $stored) {
@@ -53,7 +54,7 @@ class UpdateUserAvatar
public function upload(User $user, UploadedFile $avatar): User public function upload(User $user, UploadedFile $avatar): User
{ {
$oldPath = $user->avatar_path; $oldPath = $user->avatar_path;
$path = $avatar->store('avatars/uploads', 'public'); $path = $avatar->store('avatars/uploads', $this->avatarDisk());
if (! is_string($path)) { if (! is_string($path)) {
throw new RuntimeException('Uploaded avatar could not be stored.'); throw new RuntimeException('Uploaded avatar could not be stored.');
@@ -90,7 +91,14 @@ class UpdateUserAvatar
return; return;
} }
Storage::disk('public')->delete($path); Storage::disk($this->avatarDisk())->delete($path);
}
private function avatarDisk(): string
{
$disk = config('filesystems.avatar_disk', 'public');
return is_string($disk) && $disk !== '' ? $disk : 'public';
} }
private function initials(string $name): string private function initials(string $name): string
+11
View File
@@ -8,6 +8,7 @@ use App\Models\User;
use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')] #[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
@@ -25,6 +26,9 @@ class CreateAdminUser extends Command
*/ */
public function handle(): int public function handle(): int
{ {
DB::beginTransaction();
try {
$user = User::create([ $user = User::create([
'name' => $this->argument('name'), 'name' => $this->argument('name'),
'email' => $this->argument('email'), 'email' => $this->argument('email'),
@@ -38,7 +42,14 @@ class CreateAdminUser extends Command
]); ]);
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password'))); $this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
DB::commit();
return self::SUCCESS; return self::SUCCESS;
} catch (\Exception $exception) {
$this->error('Admin User could not be created: ', $exception->getMessage());
DB::rollBack();
return self::FAILURE;
}
} }
} }
+19 -3
View File
@@ -77,12 +77,28 @@ class User extends Authenticatable
protected function avatar(): Attribute protected function avatar(): Attribute
{ {
return Attribute::get( return Attribute::get(
fn (): ?string => filled($this->avatar_path) fn (): ?string => $this->avatarUrl(),
? Storage::disk('public')->url($this->avatar_path)
: null,
); );
} }
private function avatarUrl(): ?string
{
if (blank($this->avatar_path)) {
return null;
}
$diskName = config('filesystems.avatar_disk', 'public');
$disk = Storage::disk(is_string($diskName) && $diskName !== '' ? $diskName : 'public');
if ((bool) config('filesystems.avatar_urls_temporary', false)) {
$expirationMinutes = max(1, (int) config('filesystems.avatar_url_expiration_minutes', 15));
return $disk->temporaryUrl($this->avatar_path, now()->addMinutes($expirationMinutes));
}
return $disk->url($this->avatar_path);
}
public function permissionAssignments(): MorphMany public function permissionAssignments(): MorphMany
{ {
return $this->morphMany(PermissionAssignment::class, 'assignee'); return $this->morphMany(PermissionAssignment::class, 'assignee');
+1
View File
@@ -9,6 +9,7 @@
"skills": [ "skills": [
"fortify-development", "fortify-development",
"laravel-best-practices", "laravel-best-practices",
"mcp-development",
"wayfinder-development", "wayfinder-development",
"inertia-vue-development", "inertia-vue-development",
"tailwindcss-development" "tailwindcss-development"
+2
View File
@@ -17,6 +17,8 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [ $middleware->web(append: [
+4 -3
View File
@@ -12,10 +12,11 @@
"php": "^8.4", "php": "^8.4",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^2.0",
"laravel/fortify": "^1.34", "laravel/fortify": "^1.34",
"laravel/framework": "^13.0", "laravel/framework": "^v13.26.1",
"laravel/mcp": "^0.6", "laravel/mcp": "^0.6",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14", "laravel/wayfinder": "^0.1.14",
"league/flysystem-aws-s3-v3": "^3.0",
"scalar/laravel": "^0.2.1" "scalar/laravel": "^0.2.1"
}, },
"require-dev": { "require-dev": {
@@ -51,7 +52,7 @@
], ],
"dev": [ "dev": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" "@php artisan dev"
], ],
"dev:ssr": [ "dev:ssr": [
"npm run build:ssr", "npm run build:ssr",
@@ -66,8 +67,8 @@
], ],
"ci:check": [ "ci:check": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",
"npm audit --audit-level=high",
"npm run lint:check", "npm run lint:check",
"npm run format:check",
"npm run types:check", "npm run types:check",
"@test" "@test"
], ],
Generated
+795 -336
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -15,6 +15,12 @@ return [
'default' => env('FILESYSTEM_DISK', 'local'), 'default' => env('FILESYSTEM_DISK', 'local'),
'avatar_disk' => env('AVATAR_FILESYSTEM_DISK', env('FILESYSTEM_DISK', 'public')),
'avatar_urls_temporary' => env('AVATAR_URLS_TEMPORARY', false),
'avatar_url_expiration_minutes' => env('AVATAR_URL_EXPIRATION_MINUTES', 15),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Filesystem Disks | Filesystem Disks
+32 -12
View File
@@ -1,33 +1,38 @@
services: services:
web: web:
container_name: kanbangit-web container_name: kanbangit-web
image: kanbangit-app:local
build: build:
context: . context: .
dockerfile: ./docker/nginx/Dockerfile dockerfile: Dockerfile
target: runtime
command: nginx
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:80" - "8080:8080"
volumes:
- kanbangit-storage:/var/www/storage:ro
- kanbangit-public-assets:/var/www/public/build:ro
env_file: env_file:
- .env.production - .env.production
environment:
PHP_FPM_UPSTREAM: php-fpm:9000
networks: networks:
- kanbangit-web - kanbangit-web
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/up"]
interval: 10s
timeout: 5s
retries: 3
depends_on: depends_on:
php-fpm: php-fpm:
condition: service_healthy condition: service_healthy
php-fpm: php-fpm:
container_name: kanbangit-php-fpm container_name: kanbangit-php-fpm
image: kanbangit-app:local
build: build:
context: . context: .
dockerfile: ./docker/php-fpm/Dockerfile dockerfile: Dockerfile
target: production target: runtime
restart: unless-stopped restart: unless-stopped
volumes:
- kanbangit-storage:/var/www/storage
- kanbangit-public-assets:/var/www/public/build
env_file: env_file:
- .env.production - .env.production
networks: networks:
@@ -42,6 +47,23 @@ services:
mariadb: mariadb:
condition: service_healthy condition: service_healthy
worker:
container_name: kanbangit-worker
image: kanbangit-app:local
build:
context: .
dockerfile: Dockerfile
target: runtime
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600
restart: unless-stopped
env_file:
- .env.production
networks:
- kanbangit-db
depends_on:
mariadb:
condition: service_healthy
mariadb: mariadb:
container_name: kanbangit-db container_name: kanbangit-db
image: mariadb:11.4 image: mariadb:11.4
@@ -65,5 +87,3 @@ networks:
volumes: volumes:
mariadb-data-production: mariadb-data-production:
kanbangit-storage:
kanbangit-public-assets:
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
if [ "${1:-}" = "nginx" ]; then
: "${PHP_FPM_UPSTREAM:=127.0.0.1:9000}"
export PHP_FPM_UPSTREAM
envsubst '${PHP_FPM_UPSTREAM}' \
< /etc/nginx/nginx.conf.template \
> /tmp/nginx.conf
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
fi
mkdir -p \
bootstrap/cache \
storage/framework/cache \
storage/framework/sessions \
storage/framework/testing \
storage/framework/views \
storage/logs
php artisan config:cache --no-ansi
php artisan route:cache --no-ansi
exec "$@"
-20
View File
@@ -1,20 +0,0 @@
FROM php:8.4-cli-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates git unzip \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sS https://getcomposer.org/installer | php -- \
--install-dir=/usr/local/bin --filename=composer
WORKDIR /var/www
COPY . /var/www
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader
RUN npm ci && npm run build
FROM nginx:alpine
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /var/www/public /var/www/public
WORKDIR /var/www/public
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-33
View File
@@ -1,33 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
client_max_body_size 100m;
server {
listen 80;
server_name localhost;
root /var/www/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ^~ /storage/ {
alias /var/www/storage/app/public/;
try_files $uri =404;
access_log off;
expires 7d;
add_header Cache-Control "public";
}
location ~ \.php$ {
fastcgi_pass php-fpm:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
client_body_temp_path /tmp/client_body;
proxy_temp_path /tmp/proxy;
fastcgi_temp_path /tmp/fastcgi;
uwsgi_temp_path /tmp/uwsgi;
scgi_temp_path /tmp/scgi;
sendfile on;
keepalive_timeout 65;
client_max_body_size 3m;
server {
listen 8080;
server_name _;
root /var/www/public;
index index.php;
location = /nginx-health {
access_log off;
default_type text/plain;
return 200 'ok';
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /index.php {
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY '';
fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;
fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port;
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
fastcgi_pass ${PHP_FPM_UPSTREAM};
}
location ~ \.php$ {
return 404;
}
location ~ /\. {
deny all;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
SCRIPT_NAME=/status \
SCRIPT_FILENAME=/status \
REQUEST_METHOD=GET \
cgi-fcgi -bind -connect 127.0.0.1:9000 2>/dev/null | grep -q '^pool:'
-66
View File
@@ -1,66 +0,0 @@
FROM php:8.4-fpm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
unzip \
libpq-dev \
libonig-dev \
libssl-dev \
libxml2-dev \
libcurl4-openssl-dev \
libicu-dev \
libzip-dev \
libmagickwand-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
pdo_pgsql \
pgsql \
opcache \
intl \
zip \
bcmath \
soap \
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
&& docker-php-ext-install -j$(nproc) gd \
&& pecl install imagick \
&& docker-php-ext-enable imagick \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
WORKDIR /var/www
COPY . /var/www
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist
FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
libicu-dev \
libzip-dev \
libfcgi-bin \
procps \
libmagickwand-dev \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
&& chmod +x /usr/local/bin/php-fpm-healthcheck
COPY ./docker/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
COPY ./storage /var/www/storage-init
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
RUN printf '%s\n' \
'[global]' \
'daemonize = no' \
'' \
'[www]' \
'pm.status_path = /status' \
> /usr/local/etc/php-fpm.d/zz-docker.conf
COPY --from=builder /var/www /var/www
WORKDIR /var/www
RUN chown -R www-data:www-data /var/www
USER www-data
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
EXPOSE 9000
CMD ["php-fpm"]
-11
View File
@@ -1,11 +0,0 @@
#!/bin/sh
set -e
if [ ! "$(ls -A /var/www/storage)" ]; then
echo "Initializing storage directory..."
cp -R /var/www/storage-init/. /var/www/storage
chown -R www-data:www-data /var/www/storage
fi
rm -rf /var/www/storage-init
php artisan config:cache
php artisan route:cache
exec "$@"
+3
View File
@@ -37,7 +37,10 @@ const eslintConfig = [
'bootstrap/cache/**', 'bootstrap/cache/**',
'public/build/**', 'public/build/**',
'public/hot', 'public/hot',
'resources/js/actions/**',
'resources/js/components/ui/**', 'resources/js/components/ui/**',
'resources/js/routes/**',
'resources/js/wayfinder/**',
'resources/views/mail/**', 'resources/views/mail/**',
], ],
}, },
+497 -198
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -19,7 +19,10 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
+36 -3
View File
@@ -5,11 +5,14 @@ namespace Tests\Feature\Models;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Database\Seeders\UserSeeder; use Database\Seeders\UserSeeder;
use DateTimeInterface;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Mockery;
use Tests\TestCase; use Tests\TestCase;
class UserTest extends TestCase class UserTest extends TestCase
@@ -32,9 +35,13 @@ class UserTest extends TestCase
]); ]);
} }
public function test_user_avatar_attribute_returns_public_storage_url(): void public function test_user_avatar_attribute_returns_configured_storage_url(): void
{ {
Storage::fake('public'); config([
'filesystems.avatar_disk' => 'avatar-test',
'filesystems.avatar_urls_temporary' => false,
]);
Storage::fake('avatar-test');
$user = User::factory()->create([ $user = User::factory()->create([
'avatar_path' => 'avatars/generated/example.svg', 'avatar_path' => 'avatars/generated/example.svg',
@@ -43,12 +50,38 @@ class UserTest extends TestCase
]); ]);
$this->assertSame( $this->assertSame(
Storage::disk('public')->url('avatars/generated/example.svg'), Storage::disk('avatar-test')->url('avatars/generated/example.svg'),
$user->avatar, $user->avatar,
); );
$this->assertArrayNotHasKey('avatar_path', $user->toArray()); $this->assertArrayNotHasKey('avatar_path', $user->toArray());
} }
public function test_user_avatar_attribute_returns_temporary_url_for_private_storage(): void
{
config([
'filesystems.avatar_disk' => 's3',
'filesystems.avatar_urls_temporary' => true,
'filesystems.avatar_url_expiration_minutes' => 10,
]);
$disk = Mockery::mock(FilesystemAdapter::class);
$disk->shouldReceive('temporaryUrl')
->once()
->with(
'avatars/uploads/private.png',
Mockery::on(fn (mixed $expiration): bool => $expiration instanceof DateTimeInterface),
)
->andReturn('https://object-storage.test/signed-avatar');
Storage::shouldReceive('disk')->once()->with('s3')->andReturn($disk);
$user = User::factory()->create([
'avatar_path' => 'avatars/uploads/private.png',
'avatar_is_generated' => false,
]);
$this->assertSame('https://object-storage.test/signed-avatar', $user->avatar);
}
public function test_user_can_identify_uploaded_avatars(): void public function test_user_can_identify_uploaded_avatars(): void
{ {
$generated = User::factory()->make([ $generated = User::factory()->make([
+5 -4
View File
@@ -72,14 +72,15 @@ class ProfileUpdateTest extends TestCase
public function test_user_can_upload_a_profile_picture() public function test_user_can_upload_a_profile_picture()
{ {
Storage::fake('public'); config(['filesystems.avatar_disk' => 'avatar-test']);
Storage::fake('avatar-test');
$user = User::factory()->create([ $user = User::factory()->create([
'avatar_path' => 'avatars/generated/old.svg', 'avatar_path' => 'avatars/generated/old.svg',
'avatar_color' => '#F97316', 'avatar_color' => '#F97316',
'avatar_is_generated' => true, 'avatar_is_generated' => true,
]); ]);
Storage::disk('public')->put($user->avatar_path, '<svg>OU</svg>'); Storage::disk('avatar-test')->put($user->avatar_path, '<svg>OU</svg>');
$response = $this $response = $this
->actingAs($user) ->actingAs($user)
@@ -100,8 +101,8 @@ class ProfileUpdateTest extends TestCase
$this->assertSame('#F97316', $user->avatar_color); $this->assertSame('#F97316', $user->avatar_color);
$this->assertNotSame('avatars/generated/old.svg', $user->avatar_path); $this->assertNotSame('avatars/generated/old.svg', $user->avatar_path);
$this->assertStringStartsWith('avatars/uploads/', $user->avatar_path); $this->assertStringStartsWith('avatars/uploads/', $user->avatar_path);
Storage::disk('public')->assertMissing('avatars/generated/old.svg'); Storage::disk('avatar-test')->assertMissing('avatars/generated/old.svg');
Storage::disk('public')->assertExists($user->avatar_path); Storage::disk('avatar-test')->assertExists($user->avatar_path);
} }
public function test_user_can_delete_their_uploaded_profile_picture() public function test_user_can_delete_their_uploaded_profile_picture()