feat(docker): replace all-in-one image with FrankenPHP and Caddy based image + discard other images

- use serversideup/php as a base image
- remove nginx unit base
- remove app / webserver images
- add bundle stage to remove pipeline dependency
- update docker setup docs
- edit gitlabci rules and release logic
This commit is contained in:
Yassine Doghri
2026-02-15 19:32:01 +01:00
parent 49a43d08cc
commit e5fb676cb6
58 changed files with 5874 additions and 5362 deletions

68
.dockerignore Normal file
View File

@@ -0,0 +1,68 @@
.env
.git/
node_modules/
vendor/
build/
docs/
scripts/
tests/
#-------------------------
# Temporary Files
#-------------------------
writable/cache/*
!writable/cache/index.html
writable/logs/*
!writable/logs/index.html
writable/session/*
!writable/session/index.html
writable/temp/*
!writable/temp/index.html
writable/uploads/*
!writable/uploads/index.html
writable/debugbar/*
!writable/debugbar/index.html
# public folder
public/*
!public/media
!public/.htaccess
!public/favicon.ico
!public/icon*
!public/castopod-banner*
!public/castopod-avatar*
!public/index.php
!public/robots.txt
!public/.well-known
!public/.well-known/GDPR.yml
public/assets/*
!public/assets/index.html
# public media folder
!public/media/podcasts
!public/media/persons
!public/media/site
public/media/podcasts/*
!public/media/podcasts/index.html
public/media/persons/*
!public/media/persons/index.html
public/media/site/*
!public/media/site/index.html
# Generated files
modules/Admin/Language/*/PersonsTaxonomy.php
# Castopod bundle & packages
castopod/
castopod-*.zip
castopod-*.tar.gz

9
.gitignore vendored
View File

@@ -175,15 +175,6 @@ public/media/site/*
# Generated files
modules/Admin/Language/*/PersonsTaxonomy.php
#-------------------------
# Docker volumes
#-------------------------
mariadb
phpmyadmin
sessions
data
# Castopod bundle & packages
castopod/
castopod-*.zip

View File

@@ -23,6 +23,10 @@ php-dependencies:
expire_in: 30 mins
paths:
- vendor/
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
js-dependencies:
stage: prepare
@@ -39,6 +43,10 @@ js-dependencies:
expire_in: 30 mins
paths:
- node_modules/
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
lint-commit-msg:
stage: quality
@@ -48,12 +56,10 @@ lint-commit-msg:
- ./scripts/lint-commit-msg.sh
dependencies:
- js-dependencies
only:
- develop
- main
- beta
- alpha
- next
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- if: $CI_COMMIT_BRANCH =~ /^(develop|main|alpha|beta|next)$/
lint-php:
stage: quality
@@ -66,6 +72,10 @@ lint-php:
- vendor/bin/rector process --dry-run --ansi
dependencies:
- php-dependencies
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
lint-js:
stage: quality
@@ -76,6 +86,10 @@ lint-js:
- pnpm run lint:css
dependencies:
- js-dependencies
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
tests:
stage: quality
@@ -94,6 +108,10 @@ tests:
- vendor/bin/phpunit --no-coverage
dependencies:
- php-dependencies
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
bundle:
stage: bundle
@@ -114,14 +132,12 @@ bundle:
name: "castopod-${CI_COMMIT_REF_SLUG}_${CI_COMMIT_SHORT_SHA}"
paths:
- castopod
only:
variables:
- $CI_PROJECT_NAMESPACE == "adaures"
except:
- main
- beta
- alpha
- next
rules:
- if: $CI_PROJECT_NAMESPACE != "adaures"
when: never
- if: $CI_COMMIT_BRANCH =~ /^(main|alpha|beta|next)$/ || $CI_COMMIT_TAG
when: never
- when: on_success
release:
stage: release
@@ -145,40 +161,38 @@ release:
artifacts:
paths:
- castopod
- CP_VERSION.env
only:
- main
- beta
- alpha
- next
rules:
- if: $CI_PROJECT_NAMESPACE != "adaures"
when: never
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- if: $CI_COMMIT_BRANCH =~ /^(main|alpha|beta|next)$/
website:
stage: deploy
trigger: adaures/castopod.org
only:
- main
- beta
- alpha
rules:
- if: $CI_PROJECT_NAMESPACE != "adaures"
when: never
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/ && $CI_COMMIT_TAG
documentation:
stage: deploy
trigger:
include: docs/.gitlab-ci.yml
strategy: depend
rules:
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
when: never
- when: on_success
docker:
stage: build
trigger:
include: docker/production/.gitlab-ci.yml
strategy: depend
variables:
PARENT_PIPELINE_ID: $CI_PIPELINE_ID
only:
refs:
- develop
- main
- beta
- alpha
- next
variables:
- $CI_PROJECT_NAMESPACE == "adaures"
rules:
- if: $CI_PROJECT_NAMESPACE != "adaures"
when: never
- if: $CI_COMMIT_BRANCH == "develop"
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/ && $CI_COMMIT_TAG

View File

@@ -93,7 +93,8 @@
"package.json",
"package-lock.json",
"CHANGELOG.md"
]
],
"message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
}
],
[

View File

@@ -45,7 +45,7 @@ class MapController extends BaseController
if (! ($found = cache($cacheName))) {
$episodes = new EpisodeModel()
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->where('location_geo is not', null)
->where('location_geo is not')
->findAll();
$found = [];
foreach ($episodes as $episode) {

View File

@@ -81,14 +81,6 @@ class BaseClip extends Entity
'updated_by' => 'integer',
];
/**
* @param array<string, mixed>|null $data
*/
public function __construct(?array $data = null)
{
parent::__construct($data);
}
public function getJobDuration(): ?int
{
if ($this->job_duration === null && $this->job_started_at && $this->job_ended_at) {

View File

@@ -30,22 +30,25 @@ if (! function_exists('set_podcast_metatags')) {
$category .= $podcast->category->apple_category;
$schema = new Schema(
new Thing('PodcastSeries', [
'name' => $podcast->title,
'headline' => $podcast->title,
'url' => current_url(),
'sameAs' => $podcast->link,
'identifier' => $podcast->guid,
'image' => $podcast->cover->feed_url,
'description' => $podcast->description,
'webFeed' => $podcast->feed_url,
'accessMode' => 'auditory',
'author' => $podcast->owner_name,
'creator' => $podcast->owner_name,
'publisher' => $podcast->publisher,
'inLanguage' => $podcast->language_code,
'genre' => $category,
]),
new Thing(
props: [
'name' => $podcast->title,
'headline' => $podcast->title,
'url' => current_url(),
'sameAs' => $podcast->link,
'identifier' => $podcast->guid,
'image' => $podcast->cover->feed_url,
'description' => $podcast->description,
'webFeed' => $podcast->feed_url,
'accessMode' => 'auditory',
'author' => $podcast->owner_name,
'creator' => $podcast->owner_name,
'publisher' => $podcast->publisher,
'inLanguage' => $podcast->language_code,
'genre' => $category,
],
type: 'PodcastSeries',
),
);
/** @var HtmlHead $head */
@@ -74,22 +77,31 @@ if (! function_exists('set_episode_metatags')) {
function set_episode_metatags(Episode $episode): void
{
$schema = new Schema(
new Thing('PodcastEpisode', [
'url' => url_to('episode', esc($episode->podcast->handle), $episode->slug),
'name' => $episode->title,
'image' => $episode->cover->feed_url,
'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ATOM),
'timeRequired' => iso8601_duration($episode->audio->duration),
'duration' => iso8601_duration($episode->audio->duration),
'associatedMedia' => new Thing('MediaObject', [
'contentUrl' => $episode->audio_url,
]),
'partOfSeries' => new Thing('PodcastSeries', [
'name' => $episode->podcast->title,
'url' => $episode->podcast->link,
]),
]),
new Thing(
props: [
'url' => url_to('episode', esc($episode->podcast->handle), $episode->slug),
'name' => $episode->title,
'image' => $episode->cover->feed_url,
'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ATOM),
'timeRequired' => iso8601_duration($episode->audio->duration),
'duration' => iso8601_duration($episode->audio->duration),
'associatedMedia' => new Thing(
props: [
'contentUrl' => $episode->audio_url,
],
type: 'MediaObject',
),
'partOfSeries' => new Thing(
props: [
'name' => $episode->podcast->title,
'url' => $episode->podcast->link,
],
type: 'PodcastSeries',
),
],
type: 'PodcastEpisode',
),
);
/** @var HtmlHead $head */
@@ -131,32 +143,50 @@ if (! function_exists('set_episode_metatags')) {
if (! function_exists('set_post_metatags')) {
function set_post_metatags(Post $post): void
{
$socialMediaPosting = new Thing('SocialMediaPosting', [
'@id' => url_to('post', esc($post->actor->username), $post->id),
'datePublished' => $post->published_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $post->actor->display_name,
'url' => $post->actor->uri,
]),
'text' => $post->message,
]);
$socialMediaPosting = new Thing(
props: [
'@id' => url_to('post', esc($post->actor->username), $post->id),
'datePublished' => $post->published_at->format(DATE_ATOM),
'author' => new Thing(
props: [
'name' => $post->actor->display_name,
'url' => $post->actor->uri,
],
type: 'Person',
),
'text' => $post->message,
],
type: 'SocialMediaPosting',
);
if ($post->episode_id !== null) {
$socialMediaPosting->__set('sharedContent', new Thing('Audio', [
'headline' => $post->episode->title,
'url' => $post->episode->link,
'author' => new Thing('Person', [
'name' => $post->episode->podcast->owner_name,
]),
]));
$socialMediaPosting->__set('sharedContent', new Thing(
props: [
'headline' => $post->episode->title,
'url' => $post->episode->link,
'author' => new Thing(
props: [
'name' => $post->episode->podcast->owner_name,
],
type: 'Person',
),
],
type: 'Audio',
));
} elseif ($post->preview_card instanceof PreviewCard) {
$socialMediaPosting->__set('sharedContent', new Thing('WebPage', [
'headline' => $post->preview_card->title,
'url' => $post->preview_card->url,
'author' => new Thing('Person', [
'name' => $post->preview_card->author_name,
]),
]));
$socialMediaPosting->__set('sharedContent', new Thing(
props: [
'headline' => $post->preview_card->title,
'url' => $post->preview_card->url,
'author' => new Thing(
props: [
'name' => $post->preview_card->author_name,
],
type: 'Person',
),
],
type: 'WebPage',
));
}
$schema = new Schema($socialMediaPosting);
@@ -183,21 +213,27 @@ if (! function_exists('set_post_metatags')) {
if (! function_exists('set_episode_comment_metatags')) {
function set_episode_comment_metatags(EpisodeComment $episodeComment): void
{
$schema = new Schema(new Thing('SocialMediaPosting', [
'@id' => url_to(
'episode-comment',
esc($episodeComment->actor->username),
$episodeComment->episode->slug,
$episodeComment->id,
),
'datePublished' => $episodeComment->created_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $episodeComment->actor->display_name,
'url' => $episodeComment->actor->uri,
]),
'text' => $episodeComment->message,
'upvoteCount' => $episodeComment->likes_count,
]));
$schema = new Schema(new Thing(
props: [
'@id' => url_to(
'episode-comment',
esc($episodeComment->actor->username),
$episodeComment->episode->slug,
$episodeComment->id,
),
'datePublished' => $episodeComment->created_at->format(DATE_ATOM),
'author' => new Thing(
props: [
'name' => $episodeComment->actor->display_name,
'url' => $episodeComment->actor->uri,
],
type: 'Person',
),
'text' => $episodeComment->message,
'upvoteCount' => $episodeComment->likes_count,
],
type: 'SocialMediaPosting',
));
/** @var HtmlHead $head */
$head = service('html_head');

View File

@@ -82,10 +82,6 @@ class RssFeed extends SimpleXMLElement
return $newChild;
}
if (is_array($value)) {
return $newChild;
}
$node->appendChild($no->createTextNode($value));
return $newChild;

View File

@@ -122,7 +122,6 @@ class ClipModel extends Model
$found[$key] = new VideoClip($videoClip->toArray());
}
// @phpstan-ignore-next-line
return $found;
}

View File

@@ -371,7 +371,7 @@ class EpisodeModel extends UuidModel
$episodeCommentsCount = new EpisodeCommentModel()
->builder()
->select('episode_id, COUNT(*) as `comments_count`')
->where('in_reply_to_id', null)
->where('in_reply_to_id')
->groupBy('episode_id')
->getCompiledSelect();
@@ -379,8 +379,8 @@ class EpisodeModel extends UuidModel
->builder()
->select('fediverse_posts.episode_id as episode_id, COUNT(*) as `comments_count`')
->join('fediverse_posts as fp', 'fediverse_posts.id = fp.in_reply_to_id')
->where('fediverse_posts.in_reply_to_id', null)
->where('fediverse_posts.episode_id IS NOT', null)
->where('fediverse_posts.in_reply_to_id')
->where('fediverse_posts.episode_id IS NOT')
->groupBy('fediverse_posts.episode_id')
->getCompiledSelect();
@@ -404,7 +404,7 @@ class EpisodeModel extends UuidModel
$episodePostsCount = $this->builder()
->select('episodes.id, COUNT(*) as `posts_count`')
->join('fediverse_posts', 'episodes.id = fediverse_posts.episode_id')
->where('in_reply_to_id', null)
->where('in_reply_to_id')
->groupBy('episodes.id')
->get()
->getResultArray();

View File

@@ -176,7 +176,7 @@ class PodcastModel extends Model
'`' . $prefix . 'fediverse_posts`.`published_at` <= UTC_TIMESTAMP()',
null,
false,
)->orWhere('fediverse_posts.published_at', null)
)->orWhere('fediverse_posts.published_at')
->groupEnd()
->groupBy('podcasts.actor_id')
->orderBy('max_published_at', 'DESC');

View File

@@ -50,7 +50,7 @@ class PostModel extends FediversePostModel
return $this->where([
'episode_id' => $episodeId,
])
->where('in_reply_to_id', null)
->where('in_reply_to_id')
->where('`published_at` <= UTC_TIMESTAMP()', null, false)
->orderBy('published_at', 'DESC')
->findAll();

View File

@@ -10,38 +10,38 @@
"adaures/castopod-plugins-manager": "dev-main",
"adaures/ipcat-php": "^v1.0.0",
"adaures/podcast-persons-taxonomy": "^v1.0.1",
"aws/aws-sdk-php": "^3.356.33",
"aws/aws-sdk-php": "^3.369.35",
"chrisjean/php-ico": "^1.0.4",
"cocur/slugify": "^v4.6.0",
"codeigniter4/framework": "4.6.3",
"cocur/slugify": "4.7.1",
"codeigniter4/framework": "4.6.5",
"codeigniter4/settings": "v2.2.0",
"codeigniter4/shield": "1.2.0",
"codeigniter4/tasks": "dev-develop",
"geoip2/geoip2": "3.2.0",
"geoip2/geoip2": "3.3.0",
"james-heinrich/getid3": "^2.0.0-beta6",
"league/commonmark": "^2.7.1",
"league/commonmark": "^2.8.0",
"league/html-to-markdown": "5.1.1",
"melbahja/seo": "^v2.1.1",
"michalsn/codeigniter4-uuid": "1.3.0",
"melbahja/seo": "3.0.2",
"michalsn/codeigniter4-uuid": "1.3.1",
"mpratt/embera": "^2.0.42",
"opawg/user-agents-v2-php": "dev-main",
"phpseclib/phpseclib": "~2.0.49",
"vlucas/phpdotenv": "5.6.2",
"phpseclib/phpseclib": "~2.0.51",
"vlucas/phpdotenv": "5.6.3",
"whichbrowser/parser": "^v2.1.8",
"yassinedoghri/codeigniter-vite": "^2.1.0",
"yassinedoghri/php-icons": "1.3.0",
"yassinedoghri/podcast-feed": "dev-main"
},
"require-dev": {
"captainhook/captainhook": "^5.25.11",
"captainhook/captainhook": "^5.28.3",
"codeigniter/phpstan-codeigniter": "1.5.4",
"mikey179/vfsstream": "^v1.6.12",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.30",
"phpunit/phpunit": "^12.4.0",
"rector/rector": "^2.2.1",
"symplify/coding-standard": "^12.4.3",
"symplify/easy-coding-standard": "^12.6.0"
"phpstan/phpstan": "^2.1.39",
"phpunit/phpunit": "^13.0.3",
"rector/rector": "^2.3.6",
"symplify/coding-standard": "^13.0.0",
"symplify/easy-coding-standard": "^13.0.4"
},
"autoload": {
"psr-4": {

1404
composer.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,9 @@ stages:
docker-build-rolling:
stage: build
image:
name: docker.io/docker:23.0.3-dind
name: docker.io/docker:29.2-dind
services:
- docker:23.0.3-dind
- docker:29.2-dind
variables:
TAG: $CI_COMMIT_BRANCH
DOCKER_BUILDKIT: 1
@@ -17,22 +17,16 @@ docker-build-rolling:
- cp ${DOCKER_HUB_CONFIG} /root/.docker/config.json
- docker context create tls-environment
- docker buildx create --use tls-environment
- docker buildx build --push --platform=linux/amd64 --file=docker/production/castopod/Dockerfile --tag=${DOCKER_IMAGE_CASTOPOD}:${TAG} .
- docker buildx build --push --platform=linux/amd64 --file=docker/production/web-server/Dockerfile --tag=${DOCKER_IMAGE_WEB_SERVER}:${TAG} .
- docker buildx build --push --platform=linux/amd64 --file=docker/production/app/Dockerfile --tag=${DOCKER_IMAGE_APP}:${TAG} .
needs:
- pipeline: $PARENT_PIPELINE_ID
job: bundle
only:
refs:
- develop
- docker buildx build --secret id=maxmind-licence-key,env=MAXMIND_LICENCE_KEY --push --platform=linux/amd64 --file=docker/production/Dockerfile --tag=${DOCKER_IMAGE_CASTOPOD}:${TAG} .
rules:
- if: $CI_COMMIT_BRANCH == 'develop'
docker-build-main-release:
docker-build-release:
stage: build
image:
name: docker.io/docker:23.0.3-dind
name: docker.io/docker:29.2-dind
services:
- docker:23.0.3-dind
- docker:29.2-dind
variables:
DOCKER_BUILDKIT: 1
DOCKER_HOST: tcp://docker:2376
@@ -40,50 +34,15 @@ docker-build-main-release:
script:
- mkdir -p /root/.docker
- cp ${DOCKER_HUB_CONFIG} /root/.docker/config.json
- export CP_VERSION=$(cat CP_VERSION.env)
# extract Castopod version from tag (remove "v" prefix)
- export CP_VERSION=$(echo "$CI_COMMIT_TAG" | sed 's/^v//')
# extract pre release identifier (eg. alpha, beta, next, ...) from CP_VERSION or "latest" if none exists
- export CP_TAG=$(echo "$CP_VERSION" | sed 's/^[^-]*-\([^.]*\)\..*/\1/; t; s/.*/latest/')
- docker context create tls-environment
- docker buildx create --use tls-environment
- docker buildx build --push --platform=linux/amd64 --file=docker/production/castopod/Dockerfile --tag=${DOCKER_IMAGE_CASTOPOD}:${CP_VERSION} --tag=${DOCKER_IMAGE_CASTOPOD}:latest .
- docker buildx build --push --platform=linux/amd64 --file=docker/production/web-server/Dockerfile --tag=${DOCKER_IMAGE_WEB_SERVER}:${CP_VERSION} --tag=${DOCKER_IMAGE_WEB_SERVER}:latest .
- docker buildx build --push --platform=linux/amd64 --file=docker/production/app/Dockerfile --tag=${DOCKER_IMAGE_APP}:${CP_VERSION} --tag=${DOCKER_IMAGE_APP}:latest .
- docker buildx build --secret id=maxmind-licence-key,env=MAXMIND_LICENCE_KEY --push --platform=linux/amd64 --file=docker/production/Dockerfile --tag=${DOCKER_IMAGE_CASTOPOD}:${CP_VERSION} --tag=${DOCKER_IMAGE_CASTOPOD}:${CP_TAG} .
# when --platform=linux/amd64,linux/arm64: amd64 image takes too long to be pushed as it needs to wait for arm64 to be built
# --> build and push amd64 image to be pushed first, then overwrite manifest after building arm64
- docker buildx build --push --platform=linux/amd64,linux/arm64 --file=docker/production/castopod/Dockerfile --tag=${DOCKER_IMAGE_CASTOPOD}:${CP_VERSION} --tag=${DOCKER_IMAGE_CASTOPOD}:latest .
needs:
- pipeline: $PARENT_PIPELINE_ID
job: release
only:
refs:
- main
docker-build-prerelease:
stage: build
image:
name: docker.io/docker:23.0.3-dind
services:
- docker:23.0.3-dind
variables:
TAG: $CI_COMMIT_BRANCH
DOCKER_BUILDKIT: 1
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
script:
- mkdir -p /root/.docker
- cp ${DOCKER_HUB_CONFIG} /root/.docker/config.json
- export CP_VERSION=$(cat CP_VERSION.env)
- docker context create tls-environment
- docker buildx create --use tls-environment
- docker buildx build --push --platform=linux/amd64 --file=docker/production/castopod/Dockerfile --tag=${DOCKER_IMAGE_CASTOPOD}:${CP_VERSION} --tag=${DOCKER_IMAGE_CASTOPOD}:${TAG} .
- docker buildx build --push --platform=linux/amd64 --file=docker/production/web-server/Dockerfile --tag=${DOCKER_IMAGE_WEB_SERVER}:${CP_VERSION} --tag=${DOCKER_IMAGE_WEB_SERVER}:${TAG} .
- docker buildx build --push --platform=linux/amd64 --file=docker/production/app/Dockerfile --tag=${DOCKER_IMAGE_APP}:${CP_VERSION} --tag=${DOCKER_IMAGE_APP}:${TAG} .
# when --platform=linux/amd64,linux/arm64: amd64 image takes too long to be pushed as it needs to wait for arm64 to be built
# --> build and push amd64 image to be pushed first, then overwrite manifest after building arm64
- docker buildx build --push --platform=linux/amd64,linux/arm64 --file=docker/production/castopod/Dockerfile --tag=${DOCKER_IMAGE_CASTOPOD}:${CP_VERSION} --tag=${DOCKER_IMAGE_CASTOPOD}:${TAG} .
needs:
- pipeline: $PARENT_PIPELINE_ID
job: release
only:
refs:
- alpha
- beta
- next
# --> build and push amd64 image first, then overwrite manifest after building arm64
- docker buildx build --secret id=maxmind-licence-key,env=MAXMIND_LICENCE_KEY --push --platform=linux/amd64,linux/arm64 --file=docker/production/Dockerfile --tag=${DOCKER_IMAGE_CASTOPOD}:${CP_VERSION} --tag=${DOCKER_IMAGE_CASTOPOD}:${CP_TAG} .
rules:
- if: $CI_COMMIT_TAG

View File

@@ -0,0 +1,135 @@
####################################################
# Castopod's Production Dockerfile
####################################################
# An optimized Dockerfile for production using
# multi-stage builds:
# 1. BUNDLE castopod
# 2. BUILD the FrankenPHP/debian based prod image
#---------------------------------------------------
ARG PHP_VERSION="8.4"
####################################################
# BUNDLE STAGE
# -------------------------------------------------
# Bundle castopod for production using
# a PHP / Alpine image
#---------------------------------------------------
FROM php:${PHP_VERSION}-alpine3.23 AS bundle
LABEL maintainer="Yassine Doghri <yassine@doghri.fr>"
COPY . /castopod-src
WORKDIR /castopod-src
COPY --from=composer:2.9 /usr/bin/composer /usr/local/bin/composer
RUN \
# download GeoLite2-City archive and extract it to writable/uploads
--mount=type=secret,id=maxmind-licence-key,env=MAXMIND_LICENCE_KEY \
wget -c "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=$MAXMIND_LICENCE_KEY&suffix=tar.gz" -O - | tar -xz -C ./writable/uploads/ \
# rename extracted archives' folders
&& mv ./writable/uploads/GeoLite2-City* ./writable/uploads/GeoLite2-City
RUN \
# install composer globally
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
# install node and pnpm
&& apk add --no-cache \
nodejs \
pnpm \
git \
rsync \
# install production dependencies only using the --no-dev option
&& composer install --no-dev --prefer-dist --no-ansi --no-interaction --no-progress --ignore-platform-reqs \
# install js dependencies based on lockfile
&& pnpm install --frozen-lockfile \
# build all production static assets (css, js, images, icons, fonts, etc.)
&& pnpm run build \
# create castopod folder bundle: uses .rsync-filter (-F) file to copy only needed files
&& rsync -aF . /castopod
####################################################
# BUILD STAGE
# -------------------------------------------------
# Define production image based on FrankenPHP /
# Debian with services managed by s6-overlay
#---------------------------------------------------
FROM serversideup/php:${PHP_VERSION}-frankenphp-trixie AS build
LABEL maintainer="Yassine Doghri <yassine@doghri.fr>"
USER root
# Latest releases available at https://github.com/aptible/supercronic/releases
ARG SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.42/supercronic-linux-amd64 \
SUPERCRONIC_SHA1SUM=b444932b81583b7860849f59fdb921217572ece2 \
SUPERCRONIC=supercronic-linux-amd64
# add supercronic to handle cron jobs
RUN \
curl -fsSLO "$SUPERCRONIC_URL" \
&& echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \
&& chmod +x "$SUPERCRONIC" \
&& mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
&& ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic
ARG S6_OVERLAY_VERSION=3.2.2.0
# add s6-overlay process manager
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp
RUN tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-x86_64.tar.xz /tmp
RUN tar -C / -Jxpf /tmp/s6-overlay-x86_64.tar.xz
# copy s6-overlay services
COPY --chown=www-data:www-data docker/production/s6-rc.d /etc/s6-overlay/s6-rc.d
# make prepare-environment executable for bootstrapping the Castopod environment
RUN chmod +x /etc/s6-overlay/s6-rc.d/bootstrap/prepare-environment.sh
RUN \
apt-get update \
&& apt-get install -y \
ffmpeg \
libfreetype6-dev \
libjpeg62-turbo-dev \
libpng-dev \
libwebp-dev \
libicu-dev \
&& install-php-extensions \
intl \
mysqli \
exif \
gd \
# As of PHP 7.4 we don't need to add --with-png
&& docker-php-ext-configure gd --with-webp --with-jpeg --with-freetype
# copy castopod bundle from bundle stage
COPY --from=bundle --chown=www-data:www-data /castopod /app
RUN \
chmod -R 550 /app/ \
&& chmod -R 770 /app/public/media/ \
&& chmod -R 770 /app/writable/ \
&& chmod 750 /app/
ARG \
PHP_MEMORY_LIMIT=512M \
PHP_MAX_EXECUTION_TIME=300 \
PHP_UPLOAD_MAX_FILE_SIZE=512M \
PHP_POST_MAX_SIZE=512M \
PHP_OPCACHE_ENABLE=1
ENV \
PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT} \
PHP_MAX_EXECUTION_TIME=${PHP_MAX_EXECUTION_TIME} \
PHP_UPLOAD_MAX_FILE_SIZE=${PHP_UPLOAD_MAX_FILE_SIZE} \
PHP_POST_MAX_SIZE=${PHP_POST_MAX_SIZE} \
PHP_OPCACHE_ENABLE=${PHP_OPCACHE_ENABLE}
USER www-data
ENTRYPOINT ["docker-php-serversideup-entrypoint"]
CMD ["/init"]

View File

@@ -1,12 +0,0 @@
#!/bin/sh
ENV_FILE_LOCATION=/var/www/castopod/.env
# Fix ownership and permissions of castopod folders
chmod -R 750 /var/www/castopod
chown -R root:www-data /var/www/castopod
chown -R www-data:www-data /var/www/castopod/writable /var/www/castopod/public/media
. /prepare_environment.sh
supervisord

View File

@@ -1,21 +0,0 @@
[supervisord]
nodaemon=true
[program:supercronic]
user=www-data
command=supercronic /crontab.txt
autostart=true
autorestart=unexpected
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:fpm]
command=/usr/local/sbin/php-fpm
autostart=true
autorestart=unexpected
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View File

@@ -1,60 +0,0 @@
{
"listeners": {
"*:8000": {
"pass": "routes"
}
},
"routes": [
{
"match": {
"uri": "~^.+\\.(css|js|jpg|jpeg|gif|png|ico|gz|svg|svgz|ttf|otf|woff|woff2|eot|mp4|ogg|ogv|webm|webp|zip|swf|map)$"
},
"action": {
"share": "/var/www/castopod/public$uri",
"response_headers": {
"X-Content-Type-Options": "nosniff",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "max-age=604800"
},
"fallback": {
"pass": "applications/castopod"
}
}
},
{
"action": {
"share": "/var/www/castopod/public$uri",
"response_headers": {
"X-Frame-Options": "sameorigin",
"X-Content-Type-Options": "nosniff",
"Access-Control-Allow-Origin": "*"
},
"fallback": {
"pass": "applications/castopod"
}
}
}
],
"applications": {
"castopod": {
"type": "php",
"root": "/var/www/castopod/public/",
"script": "index.php"
}
},
"access_log": {
"path": "/dev/stdout"
},
"settings": {
"http": {
"body_read_timeout": $CP_TIMEOUT,
"max_body_size": $CP_MAX_BODY_SIZE_BYTES,
"static": {
"mime_types": {
"text/vtt": [".vtt"],
"text/srt": [".srt"]
}
}
}
}
}

View File

@@ -1,8 +0,0 @@
#!/bin/sh
ENV_FILE_LOCATION=/var/www/castopod/.env
. /prepare_environment.sh
cat /config.template.json | envsubst '$CP_MAX_BODY_SIZE_BYTES$CP_TIMEOUT' > /usr/local/var/lib/unit/conf.json
supervisord

View File

@@ -1,20 +0,0 @@
[supervisord]
nodaemon=true
[program:supercronic]
user=www-data
command=supercronic /crontab.txt
autostart=true
autorestart=unexpected
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:unit]
command=unitd --no-daemon
autostart=true
autorestart=unexpected
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0

View File

@@ -1 +0,0 @@
* * * * * /usr/local/bin/php /var/www/castopod/spark tasks:run >> /dev/null 2>&1

View File

@@ -1,6 +0,0 @@
file_uploads = On
memory_limit = $CP_PHP_MEMORY_LIMIT
upload_max_filesize = $CP_MAX_BODY_SIZE
post_max_size = $CP_MAX_BODY_SIZE
max_execution_time = $CP_TIMEOUT
max_input_time = $CP_TIMEOUT

View File

View File

@@ -1,4 +1,6 @@
#!/bin/sh
#!/command/with-contenv sh
ENV_FILE_LOCATION=/app/.env
log_error() {
printf "\033[0;31mERROR:\033[0m $1\n"
@@ -9,6 +11,13 @@ log_warning() {
printf "\033[0;33mWARNING:\033[0m $1\n"
}
log_info() {
printf "\033[0;34mINFO:\033[0m $1\n"
}
# Remove .env file if exists to recreate it.
rm -f $ENV_FILE_LOCATION
if [ -z "${CP_BASEURL}" ]
then
log_error "CP_BASEURL must be set"
@@ -16,19 +25,19 @@ fi
if [ -z "${CP_MEDIA_BASEURL}" ]
then
echo "CP_MEDIA_BASEURL is empty, using CP_BASEURL by default"
log_info "CP_MEDIA_BASEURL is empty, using CP_BASEURL by default"
CP_MEDIA_BASEURL=$CP_BASEURL
fi
if [ -z "${CP_ADMIN_GATEWAY}" ]
then
echo "CP_ADMIN_GATEWAY is empty, using default"
log_info "CP_ADMIN_GATEWAY is empty, using default \"cp-admin\""
CP_ADMIN_GATEWAY="cp-admin"
fi
if [ -z "${CP_AUTH_GATEWAY}" ]
then
echo "CP_AUTH_GATEWAY is empty, using default"
log_info "CP_AUTH_GATEWAY is empty, using default \"cp-auth\""
CP_AUTH_GATEWAY="cp-auth"
fi
@@ -39,13 +48,13 @@ fi
if [ -z "${CP_DATABASE_HOSTNAME}" ]
then
log_warning "CP_DATABASE_HOSTNAME is empty, using default"
log_warning "CP_DATABASE_HOSTNAME is empty, using default \"mariadb\""
CP_DATABASE_HOSTNAME="mariadb"
fi
if [ -z "${CP_DATABASE_PREFIX}" ]
then
echo "CP_DATABASE_PREFIX is empty, using default"
log_info "CP_DATABASE_PREFIX is empty, using default \"cp_\""
CP_DATABASE_PREFIX="cp_"
fi
@@ -84,29 +93,28 @@ fi
if [ ! -z "${CP_REDIS_HOST}" ]
then
echo "Using redis cache handler"
log_info "Using redis cache handler"
CP_CACHE_HANDLER="redis"
if [ -z "${CP_REDIS_PASSWORD}" ]
then
echo "CP_REDIS_PASSWORD is empty, using default"
CP_REDIS_PASSWORD="null"
log_error "You must set CP_REDIS_PASSWORD when using redis as a cache handler."
else
CP_REDIS_PASSWORD="\"${CP_REDIS_PASSWORD}\""
fi
if [ -z "${CP_REDIS_PORT}" ]
then
echo "CP_REDIS_PORT is empty, using default"
log_info "CP_REDIS_PORT is empty, using default port \"6379\""
CP_REDIS_PORT="6379"
fi
if [ -z "${CP_REDIS_DATABASE}" ]
then
echo "CP_REDIS_DATABASE is empty, using default"
log_info "CP_REDIS_DATABASE is empty, using default \"0\""
CP_REDIS_DATABASE="0"
fi
else
echo "Using file cache handler"
log_info "Using file cache handler"
CP_CACHE_HANDLER="file"
fi
@@ -134,28 +142,6 @@ then
fi
fi
if [ -z "${CP_PHP_MEMORY_LIMIT}" ]
then
export CP_PHP_MEMORY_LIMIT="512M"
fi
if [ -z "${CP_MAX_BODY_SIZE}" ]
then
export CP_MAX_BODY_SIZE="512M"
fi
CP_MAX_BODY_SIZE_BYTES=$(numfmt --from=iec "$CP_MAX_BODY_SIZE")
if [ $? -ne 0 ]
then
log_error "Failed to parse CP_MAX_BODY_SIZE ($CP_MAX_BODY_SIZE) as human readable number"
fi
export CP_MAX_BODY_SIZE_BYTES=$CP_MAX_BODY_SIZE_BYTES
if [ -z "${CP_TIMEOUT}" ]
then
export CP_TIMEOUT=900
fi
cat << EOF > $ENV_FILE_LOCATION
app.baseURL="${CP_BASEURL}"
media.baseURL="${CP_MEDIA_BASEURL}"
@@ -238,20 +224,17 @@ if [ ! -z "${CP_EMAIL_SMTP_HOST}" ]
then
if [ -z "${CP_EMAIL_SMTP_USERNAME}" ]
then
echo "When CP_EMAIL_SMTP_HOST is provided, CP_EMAIL_SMTP_USERNAME must be set"
exit 1
log_error "When CP_EMAIL_SMTP_HOST is provided, CP_EMAIL_SMTP_USERNAME must be set"
fi
if [ -z "${CP_EMAIL_SMTP_PASSWORD}" ]
then
echo "When CP_EMAIL_SMTP_HOST is provided, CP_EMAIL_SMTP_PASSWORD must be set"
exit 1
log_error "When CP_EMAIL_SMTP_HOST is provided, CP_EMAIL_SMTP_PASSWORD must be set"
fi
if [ -z "${CP_EMAIL_FROM}" ]
then
echo "When CP_EMAIL_SMTP_HOST is provided, CP_EMAIL_FROM must be set"
exit 1
log_error "When CP_EMAIL_SMTP_HOST is provided, CP_EMAIL_FROM must be set"
fi
cat << EOF >> $ENV_FILE_LOCATION
@@ -273,8 +256,7 @@ EOF
then
if [ "${CP_EMAIL_SMTP_CRYPTO}" != "ssl" ] && [ "${CP_EMAIL_SMTP_CRYPTO}" != "tls" ]
then
echo "CP_EMAIL_SMTP_CRYPTO must be ssl or tls"
exit 1
log_error "CP_EMAIL_SMTP_CRYPTO must be ssl or tls"
fi
cat << EOF >> $ENV_FILE_LOCATION
email.SMTPCrypto=${CP_EMAIL_SMTP_CRYPTO}
@@ -282,14 +264,14 @@ EOF
fi
fi
echo "Using config:"
log_info "Using config:"
cat $ENV_FILE_LOCATION
#Run database migrations after 10 seconds (to wait for the database to be started)
(sleep 10 && php spark castopod:database-update) &
# prevent .env from being writable
chmod -w $ENV_FILE_LOCATION
#Run database migrations
/usr/local/bin/php /var/www/html/spark castopod:database-update
# clear cache to account for new assets and any change in data structure
php spark cache:clear
#Apply php configuration
cat /uploads.template.ini | envsubst '$CP_MAX_BODY_SIZE$CP_MAX_BODY_SIZE_BYTES$CP_TIMEOUT$CP_PHP_MEMORY_LIMIT' > /usr/local/etc/php/conf.d/uploads.ini
/usr/local/bin/php /var/www/html/spark cache:clear

View File

@@ -0,0 +1 @@
oneshot

View File

@@ -0,0 +1,2 @@
#!/command/with-contenv sh
/etc/s6-overlay/s6-rc.d/bootstrap/prepare-environment.sh

View File

View File

@@ -0,0 +1,2 @@
#!/command/with-contenv sh
frankenphp run --config /etc/frankenphp/Caddyfile --adapter caddyfile

View File

@@ -0,0 +1 @@
longrun

View File

@@ -0,0 +1 @@
* * * * * /usr/local/bin/php /var/www/html/spark tasks:run >> /dev/null 2>&1

View File

@@ -0,0 +1,2 @@
#!/command/with-contenv sh
supercronic /etc/s6-overlay/s6-rc.d/supercronic/crontab

View File

@@ -0,0 +1 @@
longrun

View File

View File

View File

View File

@@ -1,18 +0,0 @@
FROM docker.io/nginx:1.29
COPY docker/production/web-server/entrypoint.sh /entrypoint.sh
COPY docker/production/web-server/nginx.template.conf /nginx.template.conf
COPY castopod/public /var/www/html
RUN chmod +x /entrypoint.sh && \
apt-get update && \
apt-get install -y curl gettext-base && \
rm -rf /var/lib/apt/lists/* && \
usermod -aG www-data nginx
HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost || exit 1
VOLUME /var/www/html/media
EXPOSE 80
WORKDIR /var/www/html
CMD ["/entrypoint.sh"]

View File

@@ -1,20 +0,0 @@
#!/bin/sh
if [ -z "${CP_APP_HOSTNAME}" ]
then
echo "CP_APP_HOSTNAME is empty, using default"
export CP_APP_HOSTNAME="app"
fi
if [ -z "${CP_MAX_BODY_SIZE}" ]
then
export CP_MAX_BODY_SIZE=512M
fi
if [ -z "${CP_TIMEOUT}" ]
then
export CP_TIMEOUT=900
fi
cat /nginx.template.conf | envsubst '$CP_APP_HOSTNAME$CP_MAX_BODY_SIZE$CP_TIMEOUT' > /etc/nginx/nginx.conf
nginx -g "daemon off;"

View File

@@ -1,80 +0,0 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
types {
text/vtt vtt;
text/srt srt;
}
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Real-IP;
upstream php-handler {
server $CP_APP_HOSTNAME:9000;
}
server {
listen 80;
root /var/www/html;
server_tokens off;
add_header X-Frame-Options sameorigin always;
add_header Permissions-Policy interest-cohort=();
add_header X-Content-Type-Options nosniff;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload;";
client_max_body_size $CP_MAX_BODY_SIZE;
client_body_timeout ${CP_TIMEOUT}s;
fastcgi_buffers 64 4K;
gzip on;
gzip_vary on;
gzip_comp_level 4;
gzip_min_length 256;
gzip_types application/atom+xml application/javascript application/rss+xml image/bmp image/svg+xml image/x-icon text/css text/plain text/html;
try_files $uri $uri/ /index.php?$args;
index index.php index.html;
location ~ \.php$ {
include fastcgi_params;
fastcgi_intercept_errors on;
fastcgi_index index.php;
fastcgi_param SERVER_NAME $host;
fastcgi_pass php-handler;
fastcgi_param SCRIPT_FILENAME /var/www/castopod/public/$fastcgi_script_name;
try_files $uri =404;
fastcgi_read_timeout 3600;
fastcgi_send_timeout 3600;
}
location ~* ^.+\.(css|js|jpg|jpeg|gif|png|ico|gz|svg|svgz|ttf|otf|woff|woff2|eot|mp4|ogg|ogv|webm|webp|zip|swf|map)$ {
add_header Access-Control-Allow-Origin "*";
expires max;
access_log off;
}
}
}

View File

@@ -28,12 +28,10 @@ build:
stage: build
script:
- pnpm run build
except:
- develop
- main
- beta
- alpha
- next
rules:
- if: $CI_COMMIT_BRANCH =~ /^(develop|main|alpha|beta|next)$/
when: never
- when: on_success
build-production:
extends: .documentation-setup
@@ -47,12 +45,8 @@ build-production:
paths:
- docs/dist/$CI_COMMIT_REF_SLUG
expire_in: 30 mins
only:
- develop
- main
- beta
- alpha
- next
rules:
- if: $CI_COMMIT_BRANCH =~ /^(develop|main|alpha|beta|next)$/
deploy:
stage: deploy
@@ -78,9 +72,5 @@ deploy:
script:
- rsync -avzuh -e "ssh -p $SSH_PORT" $SOURCE_FOLDER $USER@$HOST:$TEMP_DIRECTORY --progress
- ssh $USER@$HOST -p $SSH_PORT "rsync -rtv $TEMP_DIRECTORY $DIRECTORY"
only:
- develop
- main
- beta
- alpha
- next
rules:
- if: $CI_COMMIT_BRANCH =~ /^(develop|main|alpha|beta|next)$/

View File

@@ -11,11 +11,11 @@
"prepare": "astro telemetry disable"
},
"dependencies": {
"@astrojs/starlight": "^0.36.0",
"@astrojs/starlight": "^0.37.6",
"@fontsource/inter": "^5.2.8",
"@fontsource/rubik": "^5.2.8",
"astro": "^5.14.1",
"sharp": "^0.34.4",
"starlight-openapi": "^0.20.0"
"astro": "^5.17.2",
"sharp": "^0.34.5",
"starlight-openapi": "^0.22.0"
}
}

2514
docs/pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,18 @@
---
title: Official Docker images
title: Official Docker image
---
Castopod pushes 3 Docker images to the Docker Hub during its automated build
process:
Castopod publishes a single official Docker image to the Docker Hub as part of
its automated build process:
- [**`castopod/castopod`**](https://hub.docker.com/r/castopod/castopod): an all
in one castopod image using nginx unit
- [**`castopod/app`**](https://hub.docker.com/r/castopod/app): the app bundle
with all of Castopod dependencies
- [**`castopod/web-server`**](https://hub.docker.com/r/castopod/web-server): an
Nginx configuration for Castopod
- [**`castopod/castopod`**](https://hub.docker.com/r/castopod/castopod): an
all-in-one image integrating [FrankenPHP](https://frankenphp.dev/) and
[Caddy](https://caddyserver.com/), optimized for production environments. It
is based on
[serversideup/php](https://serversideup.net/open-source/docker-php/docs/image-variations/frankenphp).
Additionally, Castopod requires a MySQL-compatible database. A Redis database
can be added as a cache handler.
Castopod requires a MySQL-compatible database to function. Optionally, a Redis
service can be configured as the caching layer.
## Supported tags
@@ -25,18 +24,16 @@ can be added as a cache handler.
## Example usage
1. Install [docker](https://docs.docker.com/get-docker/) and
[docker-compose](https://docs.docker.com/compose/install/)
2. Create a `docker-compose.yml` file with the following:
[docker compose](https://docs.docker.com/compose/install/)
2. Create a `compose.yml` file with the following:
```yml
version: "3.7"
services:
castopod:
image: castopod/castopod:latest
container_name: "castopod"
volumes:
- castopod-media:/var/www/castopod/public/media
- castopod-media:/app/public/media
environment:
MYSQL_DATABASE: castopod
MYSQL_USER: castopod
@@ -47,14 +44,28 @@ can be added as a cache handler.
CP_REDIS_HOST: redis
CP_REDIS_PASSWORD: changeme
networks:
- castopod
- castopod-app
- castopod-db
ports:
- 8000:8000
- "8080:8080" # HTTP
- "8443:8443" # HTTPS
- "8443:8443/udp" # HTTP/3
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s # allows bootstrap/migrations time
depends_on:
mariadb:
condition: service_healthy
restart: true
redis:
condition: service_started
mariadb:
image: mariadb:11.2
image: mariadb:12.1
container_name: "castopod-mariadb"
networks:
- castopod-db
@@ -66,15 +77,21 @@ can be added as a cache handler.
MYSQL_USER: castopod
MYSQL_PASSWORD: changeme
restart: unless-stopped
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
timeout: 5s
retries: 3
redis:
image: redis:7.2-alpine
image: redis:8.4-alpine
container_name: "castopod-redis"
command: --requirepass changeme
volumes:
- castopod-cache:/data
networks:
- castopod
- castopod-app
volumes:
castopod-media:
@@ -82,8 +99,9 @@ can be added as a cache handler.
castopod-cache:
networks:
castopod:
castopod-app:
castopod-db:
internal: true
```
You have to adapt some variables to your needs (e.g. `CP_BASEURL`,
@@ -97,61 +115,53 @@ can be added as a cache handler.
```
#castopod
castopod.example.com {
reverse_proxy localhost:8000
reverse_proxy localhost:8080
}
```
4. Run `docker-compose up -d`, wait for it to initialize and head on to
4. Run `docker compose up -d`, wait for it to initialize and head on to
`https://castopod.example.com/cp-install` to finish setting up Castopod!
5. You're all set, start podcasting! 🎙️🚀
## Environment Variables
- **castopod/castopod** and **castopod/app**
| Variable name | Type (`default`) | Default |
| ------------------------------------- | ----------------------- | ---------------- |
| **`CP_BASEURL`** | string | `undefined` |
| **`CP_MEDIA_BASEURL`** | ?string | `CP_BASEURL` |
| **`CP_ADMIN_GATEWAY`** | ?string | `"cp-admin"` |
| **`CP_AUTH_GATEWAY`** | ?string | `"cp-auth"` |
| **`CP_ANALYTICS_SALT`** | string | `undefined` |
| **`CP_DATABASE_HOSTNAME`** | ?string | `"mariadb"` |
| **`CP_DATABASE_NAME`** | ?string | `MYSQL_DATABASE` |
| **`CP_DATABASE_USERNAME`** | ?string | `MYSQL_USER` |
| **`CP_DATABASE_PASSWORD`** | ?string | `MYSQL_PASSWORD` |
| **`CP_DATABASE_PREFIX`** | ?string | `"cp_"` |
| **`CP_CACHE_HANDLER`** | [`"file"` or `"redis"`] | `"file"` |
| **`CP_REDIS_HOST`** | ?string | `"localhost"` |
| **`CP_REDIS_PASSWORD`** | ?string | `null` |
| **`CP_REDIS_PORT`** | ?number | `6379` |
| **`CP_REDIS_DATABASE`** | ?number | `0` |
| **`CP_EMAIL_SMTP_HOST`** | ?string | `undefined` |
| **`CP_EMAIL_FROM`** | ?string | `undefined` |
| **`CP_EMAIL_SMTP_USERNAME`** | ?string | `"localhost"` |
| **`CP_EMAIL_SMTP_PASSWORD`** | ?string | `null` |
| **`CP_EMAIL_SMTP_PORT`** | ?number | `25` |
| **`CP_EMAIL_SMTP_CRYPTO`** | [`"tls"` or `"ssl"`] | `"tls"` |
| **`CP_ENABLE_2FA`** | ?boolean | `undefined` |
| **`CP_MEDIA_FILE_MANAGER`** | ?string | `undefined` |
| **`CP_MEDIA_S3_ENDPOINT`** | ?string | `undefined` |
| **`CP_MEDIA_S3_KEY`** | ?string | `undefined` |
| **`CP_MEDIA_S3_SECRET`** | ?string | `undefined` |
| **`CP_MEDIA_S3_REGION`** | ?string | `undefined` |
| **`CP_MEDIA_S3_BUCKET`** | ?string | `undefined` |
| **`CP_MEDIA_S3_PROTOCOL`** | ?number | `undefined` |
| **`CP_MEDIA_S3_PATH_STYLE_ENDPOINT`** | ?boolean | `undefined` |
| **`CP_MEDIA_S3_KEY_PREFIX`** | ?string | `undefined` |
| **`CP_DISABLE_HTTPS`** | ?[`0` or `1`] | `undefined` |
| **`CP_MAX_BODY_SIZE`** | ?number (with suffix) | `512M` |
| **`CP_PHP_MEMORY_LIMIT`** | ?number (with suffix) | `512M` |
| **`CP_TIMEOUT`** | ?number | `900` |
- **castopod/web-server**
| Variable name | Type | Default |
| ---------------------- | --------------------- | ------- |
| **`CP_APP_HOSTNAME`** | ?string | `"app"` |
| **`CP_MAX_BODY_SIZE`** | ?number (with suffix) | `512M` |
| **`CP_TIMEOUT`** | ?number | `900` |
| Variable name | Type (`default`) | Default |
| ------------------------------------- | ----------------------- | ---------------- |
| **`CP_BASEURL`** | string | `undefined` |
| **`CP_MEDIA_BASEURL`** | ?string | `CP_BASEURL` |
| **`CP_ADMIN_GATEWAY`** | ?string | `"cp-admin"` |
| **`CP_AUTH_GATEWAY`** | ?string | `"cp-auth"` |
| **`CP_ANALYTICS_SALT`** | string | `undefined` |
| **`CP_DATABASE_HOSTNAME`** | ?string | `"mariadb"` |
| **`CP_DATABASE_NAME`** | ?string | `MYSQL_DATABASE` |
| **`CP_DATABASE_USERNAME`** | ?string | `MYSQL_USER` |
| **`CP_DATABASE_PASSWORD`** | ?string | `MYSQL_PASSWORD` |
| **`CP_DATABASE_PREFIX`** | ?string | `"cp_"` |
| **`CP_CACHE_HANDLER`** | [`"file"` or `"redis"`] | `"file"` |
| **`CP_REDIS_HOST`** | ?string | `"localhost"` |
| **`CP_REDIS_PASSWORD`** | ?string | `null` |
| **`CP_REDIS_PORT`** | ?number | `6379` |
| **`CP_REDIS_DATABASE`** | ?number | `0` |
| **`CP_EMAIL_SMTP_HOST`** | ?string | `undefined` |
| **`CP_EMAIL_FROM`** | ?string | `undefined` |
| **`CP_EMAIL_SMTP_USERNAME`** | ?string | `"localhost"` |
| **`CP_EMAIL_SMTP_PASSWORD`** | ?string | `null` |
| **`CP_EMAIL_SMTP_PORT`** | ?number | `25` |
| **`CP_EMAIL_SMTP_CRYPTO`** | [`"tls"` or `"ssl"`] | `"tls"` |
| **`CP_ENABLE_2FA`** | ?boolean | `undefined` |
| **`CP_MEDIA_FILE_MANAGER`** | ?string | `undefined` |
| **`CP_MEDIA_S3_ENDPOINT`** | ?string | `undefined` |
| **`CP_MEDIA_S3_KEY`** | ?string | `undefined` |
| **`CP_MEDIA_S3_SECRET`** | ?string | `undefined` |
| **`CP_MEDIA_S3_REGION`** | ?string | `undefined` |
| **`CP_MEDIA_S3_BUCKET`** | ?string | `undefined` |
| **`CP_MEDIA_S3_PROTOCOL`** | ?number | `undefined` |
| **`CP_MEDIA_S3_PATH_STYLE_ENDPOINT`** | ?boolean | `undefined` |
| **`CP_MEDIA_S3_KEY_PREFIX`** | ?string | `undefined` |
| **`CP_DISABLE_HTTPS`** | ?[`0` or `1`] | `undefined` |
| **`PHP_MEMORY_LIMIT`** | ?number (with suffix) | `512M` |
| **`PHP_UPLOAD_MAX_FILE_SIZE`** | ?number (with suffix) | `512M` |
| **`PHP_POST_MAX_SIZE`** | ?number (with suffix) | `512M` |
| **`PHP_MAX_EXECUTION_TIME`** | ?number | `300` |
| **`PHP_OPCACHE_ENABLE`** | ?[`0` or `1`] | `1` |

View File

@@ -5,10 +5,11 @@ title: Manage Podcast contributors
The **Persons** section allows you to add podcast contributors. It is needed in
the Podcast section to assign roles and is also used on the **Credits** page
linked from your podcast's homepage. When Persons are assigned to a specific
episode, there will be a link on the episode's page to list all persons assigned.
episode, there will be a link on the episode's page to list all persons
assigned.
A Person must be created in the **Persons** section before it can be [assigned
to an episode](../podcast/episodes#persons).
A Person must be created in the **Persons** section before it can be
[assigned to an episode](../podcast/episodes#persons).
From the left hand navigation, press `Persons` to expand the menu. To view a
list of all people that have been added to Castopod, press `All Persons`.

View File

@@ -119,9 +119,9 @@ will be displayed.
You can add a transcript to your episode by choosing a file in SRT or VTT format
to upload. Transcripts will be shown in a tab on the episode page and some
podcast apps such as Apple Podcasts can display the transcript.
Transcripts help users who may have a hearing disability and can also help with
search engine optimization.
podcast apps such as Apple Podcasts can display the transcript. Transcripts help
users who may have a hearing disability and can also help with search engine
optimization.
#### Chapters

View File

@@ -75,7 +75,7 @@ class NotificationController extends BaseController
{
$notifications = new NotificationModel()
->where('target_actor_id', $podcast->actor_id)
->where('read_at', null)
->where('read_at')
->findAll();
foreach ($notifications as $notification) {

View File

@@ -677,7 +677,7 @@ class PodcastController extends BaseController
$episodes = new EpisodeModel()
->where('podcast_id', $podcast->id)
->where('published_at !=', null)
->where('published_at !=')
->findAll();
foreach ($episodes as $episode) {
@@ -846,7 +846,7 @@ class PodcastController extends BaseController
$episodes = new EpisodeModel()
->where('podcast_id', $podcast->id)
->where('published_at !=', null)
->where('published_at !=')
->findAll();
foreach ($episodes as $episode) {
@@ -914,7 +914,7 @@ class PodcastController extends BaseController
$episodes = new EpisodeModel()
->where('podcast_id', $podcast->id)
->where('published_at !=', null)
->where('published_at !=')
->findAll();
foreach ($episodes as $episode) {

View File

@@ -52,7 +52,7 @@ class EpisodeController extends BaseApiController
(int) $this->request->getGet('offset'),
);
array_map(static function ($episode): void {
array_map(static function (Episode $episode): void {
self::mapEpisode($episode);
}, $data);

View File

@@ -20,7 +20,7 @@ class PodcastController extends BaseApiController
/** @var array<string,mixed> $data */
$data = new PodcastModel()
->findAll();
array_map(static function ($podcast): void {
array_map(static function (Podcast $podcast): void {
self::mapPodcast($podcast);
}, $data);
return $this->respond($data);

View File

@@ -283,7 +283,7 @@ if (! function_exists('get_actor_ids_with_unread_notifications')) {
$unreadNotifications = new NotificationModel()
->whereIn('target_actor_id', array_column($userPodcasts, 'actor_id'))
->where('read_at', null)
->where('read_at')
->findAll();
return array_column($unreadNotifications, 'target_actor_id');

View File

@@ -42,7 +42,7 @@ abstract class AbstractObject
}
// removes all NULL, FALSE and Empty Strings but leaves 0 (zero) values
return array_filter($array, static fn ($value): bool => $value !== null && $value !== false && $value !== '');
return array_filter($array, static fn ($value): bool => ! in_array($value, [null, false, ''], true));
}
public function toJSON(): string

View File

@@ -120,7 +120,7 @@ class SubscriptionModel extends Model
'status' => 'active',
])
->groupStart()
->where('expires_at', null)
->where('expires_at')
->orWhere('`expires_at` > UTC_TIMESTAMP()', null, false)
->groupEnd()
->first();

View File

@@ -32,81 +32,81 @@
"dependencies": {
"@amcharts/amcharts4": "^4.10.40",
"@amcharts/amcharts4-geodata": "^4.1.31",
"@codemirror/commands": "^6.9.0",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/language": "^6.11.3",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.5",
"@floating-ui/dom": "^1.7.4",
"@codemirror/language": "^6.12.1",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.14",
"@floating-ui/dom": "^1.7.5",
"@github/clipboard-copy-element": "^1.3.0",
"@github/hotkey": "^3.1.1",
"@github/markdown-toolbar-element": "^2.2.3",
"@github/relative-time-element": "^4.4.8",
"@patternfly/elements": "^4.2.0",
"@github/relative-time-element": "^5.0.0",
"@patternfly/elements": "^4.3.1",
"@vime/core": "^5.4.1",
"choices.js": "^11.1.0",
"codemirror": "^6.0.2",
"flatpickr": "^4.6.13",
"htmlfy": "^1.0.0",
"htmlfy": "^1.0.1",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lit": "^3.3.1",
"marked": "^16.4.0",
"wavesurfer.js": "^7.11.0",
"lit": "^3.3.2",
"marked": "^17.0.2",
"wavesurfer.js": "^7.12.1",
"xml-formatter": "^3.6.7"
},
"devDependencies": {
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@csstools/css-tokenizer": "^3.0.4",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.37.0",
"@commitlint/cli": "^20.4.1",
"@commitlint/config-conventional": "^20.4.1",
"@csstools/css-tokenizer": "^4.0.0",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^10.0.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/gitlab": "^13.2.9",
"@tailwindcss/forms": "^0.5.10",
"@semantic-release/gitlab": "^13.3.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@types/leaflet": "^1.9.20",
"@types/leaflet": "^1.9.21",
"all-contributors-cli": "^6.26.1",
"commitizen": "^4.3.1",
"conventional-changelog-conventionalcommits": "^9.1.0",
"cross-env": "^10.1.0",
"cssnano": "^7.1.1",
"cssnano": "^7.1.2",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^9.37.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"glob": "^11.0.3",
"globals": "^16.4.0",
"eslint-plugin-prettier": "^5.5.5",
"glob": "^13.0.5",
"globals": "^17.3.0",
"husky": "^9.1.7",
"is-ci": "^4.1.0",
"lint-staged": "^16.2.3",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"postcss-nesting": "^13.0.2",
"postcss-preset-env": "^10.4.0",
"postcss-nesting": "^14.0.0",
"postcss-preset-env": "^11.1.3",
"postcss-reporter": "^7.1.0",
"prettier": "3.6.2",
"prettier": "3.8.1",
"prettier-plugin-organize-imports": "^4.3.0",
"semantic-release": "^24.2.9",
"sharp": "^0.34.4",
"stylelint": "^16.25.0",
"stylelint-config-standard": "^39.0.1",
"semantic-release": "^25.0.3",
"sharp": "^0.34.5",
"stylelint": "^17.3.0",
"stylelint-config-standard": "^40.0.0",
"svgo": "^4.0.0",
"tailwindcss": "^3.4.18",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.0",
"vite": "^7.1.9",
"typescript-eslint": "^8.56.0",
"vite": "^7.3.1",
"vite-plugin-codeigniter": "^2.0.0",
"vite-plugin-inspect": "^11.3.3",
"vite-plugin-pwa": "^1.0.3",
"vite-plugin-static-copy": "^3.1.3",
"workbox-build": "^7.3.0",
"workbox-core": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0"
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-static-copy": "^3.2.0",
"workbox-build": "^7.4.0",
"workbox-core": "^7.4.0",
"workbox-routing": "^7.4.0",
"workbox-strategies": "^7.4.0"
},
"lint-staged": {
"*.{js,ts,css,md,json}": "prettier --write",

6049
pnpm-lock.yaml generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
use Rector\CodeQuality\Rector\ClassMethod\ExplicitReturnNullRector;
use Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector;
use Rector\CodingStyle\Rector\Stmt\NewlineAfterStatementRector;
use Rector\CodingStyle\Rector\String_\SymplifyQuoteEscapeRector;
use Rector\CodingStyle\Rector\String_\SimplifyQuoteEscapeRector;
use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\If_\UnwrapFutureCompatibleIfPhpVersionRector;
use Rector\DeadCode\Rector\Stmt\RemoveUnreachableStatementRector;
@@ -48,7 +48,7 @@ return RectorConfig::configure()
__DIR__ . '/app/Language/*',
__DIR__ . '/modules/*/Language/*',
],
SymplifyQuoteEscapeRector::class => [__DIR__ . '/app/Language/*', __DIR__ . '/modules/*/Language/*'],
SimplifyQuoteEscapeRector::class => [__DIR__ . '/app/Language/*', __DIR__ . '/modules/*/Language/*'],
NewlineAfterStatementRector::class => [__DIR__ . '/app/Views'],

View File

@@ -191,26 +191,23 @@ function formatXML(contents: string) {
return contents;
}
let editorContents = "";
try {
editorContents = xmlFormat(contents, {
return xmlFormat(contents, {
indentation: " ",
});
} catch {
// xml doesn't have a root node
editorContents = xmlFormat("<root>" + contents + "</root>", {
const editorContents = xmlFormat("<root>" + contents + "</root>", {
indentation: " ",
});
// remove root, unnecessary lines and indents
editorContents = editorContents
return editorContents
.replace(/^<root>/, "")
.replace(/<\/root>$/, "")
.replace(/^\s*[\r\n]/gm, "")
.replace(/[\r\n] {2}/gm, "\r\n")
.trim();
}
return editorContents;
}
function minifyXML(contents: string) {
@@ -218,20 +215,15 @@ function minifyXML(contents: string) {
return contents;
}
let minifiedContent = "";
try {
minifiedContent = xmlFormat.minify(contents, {
return xmlFormat.minify(contents, {
collapseContent: true,
});
} catch {
minifiedContent = xmlFormat.minify(`<root>${contents}</root>`, {
const minifiedContent = xmlFormat.minify(`<root>${contents}</root>`, {
collapseContent: true,
});
// remove root
minifiedContent = minifiedContent
.replace(/^<root>/, "")
.replace(/<\/root>$/, "");
return minifiedContent.replace(/^<root>/, "").replace(/<\/root>$/, "");
}
return minifiedContent;
}

View File

@@ -11,9 +11,6 @@ echo "$( jq '.version = "'$COMPOSER_VERSION'"' composer.json )" > composer.json
# replace CP_VERSION constant in app/config/constants
sed -i "s/^defined('CP_VERSION').*/defined('CP_VERSION') || define('CP_VERSION', '$VERSION');/" ./app/Config/Constants.php
# fill CP_VERSION.env for docker build
echo "$VERSION" > ./CP_VERSION.env
# download GeoLite2-City archive and extract it to writable/uploads
wget -c "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=$MAXMIND_LICENCE_KEY&suffix=tar.gz" -O - | tar -xz -C ./writable/uploads/