feat: add containerized deployment workflow
CI/CD / test_and_check_app (push) Failing after 7m26s
CI/CD / Publish immutable image (push) Skipped

This commit is contained in:
2026-08-22 08:57:18 +02:00
parent e8ee23371b
commit afe99ea6d4
25 changed files with 1334 additions and 421 deletions
+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 \
.
-48
View File
@@ -1,48 +0,0 @@
name: tests
on:
push:
branches:
- main
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 --from-lock-file
- 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: composer ci:check
+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}"
} >> "${GITEA_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
+107
View File
@@ -0,0 +1,107 @@
ARG COMPOSER_IMAGE=composer:2.9.5
ARG NODE_IMAGE=node:22-bookworm-slim
ARG PHP_IMAGE=php:8.4-fpm-bookworm
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 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/framework/cache \
/var/www/storage/framework/sessions \
/var/www/storage/framework/testing \
/var/www/storage/framework/views \
/var/www/storage/logs \
&& 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
+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');
+2 -1
View File
@@ -16,6 +16,7 @@
"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": {
@@ -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
+354 -10
View File
@@ -4,8 +4,159 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "eff91ae30c1ac1ced1e64c7c3e8df7cc", "content-hash": "7d965721310f81816807d6b78c5f9654",
"packages": [ "packages": [
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.393.4",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "a5880510e500aa0fe13a6410183cd222b3263890"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a5880510e500aa0fe13a6410183cd222b3263890",
"reference": "a5880510e500aa0fe13a6410183cd222b3263890",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
"guzzlehttp/promises": "^2.0.3 || ^3.0",
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
"mtdowling/jmespath.php": "^2.9.1",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.393.4"
},
"time": "2026-08-21T18:24:57+00:00"
},
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
"version": "v3.1.1", "version": "v3.1.1",
@@ -2122,6 +2273,61 @@
}, },
"time": "2026-07-06T14:42:07+00:00" "time": "2026-07-06T14:42:07+00:00"
}, },
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.35.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4",
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.371.5",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2"
},
"time": "2026-07-01T23:25:49+00:00"
},
{ {
"name": "league/flysystem-local", "name": "league/flysystem-local",
"version": "3.31.0", "version": "3.31.0",
@@ -2512,6 +2718,72 @@
], ],
"time": "2026-01-02T08:56:05+00:00" "time": "2026-01-02T08:56:05+00:00"
}, },
{
"name": "mtdowling/jmespath.php",
"version": "2.9.2",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.52"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.9-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
},
"time": "2026-07-06T18:56:19+00:00"
},
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.13.2", "version": "3.13.2",
@@ -4546,6 +4818,77 @@
], ],
"time": "2026-06-05T06:23:12+00:00" "time": "2026-06-05T06:23:12+00:00"
}, },
{
"name": "symfony/filesystem",
"version": "v8.1.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "17856b7a222664a26a5ea1cb06ee0721c2438217"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217",
"reference": "17856b7a222664a26a5ea1cb06ee0721c2438217",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v8.1.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-07-22T15:42:13+00:00"
},
{ {
"name": "symfony/finder", "name": "symfony/finder",
"version": "v8.1.1", "version": "v8.1.1",
@@ -8943,27 +9286,28 @@
}, },
{ {
"name": "symfony/yaml", "name": "symfony/yaml",
"version": "v8.0.8", "version": "v8.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/yaml.git", "url": "https://github.com/symfony/yaml.git",
"reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" "reference": "faabdbe998e8c5c599dceffa27aa265b185c0736"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", "url": "https://api.github.com/repos/symfony/yaml/zipball/faabdbe998e8c5c599dceffa27aa265b185c0736",
"reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", "reference": "faabdbe998e8c5c599dceffa27aa265b185c0736",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.4", "php": ">=8.4.1",
"symfony/polyfill-ctype": "^1.8" "symfony/polyfill-ctype": "^1.8"
}, },
"conflict": { "conflict": {
"symfony/console": "<7.4" "symfony/console": "<7.4"
}, },
"require-dev": { "require-dev": {
"symfony/console": "^7.4|^8.0" "symfony/console": "^7.4|^8.0",
"yaml/yaml-test-suite": "*"
}, },
"bin": [ "bin": [
"Resources/bin/yaml-lint" "Resources/bin/yaml-lint"
@@ -8994,7 +9338,7 @@
"description": "Loads and dumps YAML files", "description": "Loads and dumps YAML files",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/yaml/tree/v8.0.8" "source": "https://github.com/symfony/yaml/tree/v8.1.2"
}, },
"funding": [ "funding": [
{ {
@@ -9014,7 +9358,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-03-30T15:14:47+00:00" "time": "2026-07-22T15:42:13+00:00"
}, },
{ {
"name": "theseer/tokenizer", "name": "theseer/tokenizer",
@@ -9076,5 +9420,5 @@
"php": "^8.4" "php": "^8.4"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.9.0"
} }
+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;
}
}
}
+54
View File
@@ -0,0 +1,54 @@
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 fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY '';
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()