Files
lsky-pro-docker/Dockerfile
T
mole404 0caef9cac0 fix(viewer): 用 CSS 把切图过渡的起点钉在屏幕中心(保留动画,去掉造成忽有忽无的关过渡补丁)
老师反馈两点:① 反向切换(图都已加载完)时仍有时带过渡、有时不带;② 想要保留过渡
动画,"直接闪出来"效果差。复核后定位到两条:
  · 上一版的 1 秒兜底定时器:慢图(>1s 才加载完)会在库读 options.transition 之前先被
    恢复,于是过渡又生效 —— 这是"有时有动画"的来源之一。
  · 上一版还有一把锁:只有上一次已恢复(saved === null)时才去关过渡。若 1 秒内连续切
    (或 viewed 未到),这一次就不关 ⇒ 又带过渡。两条叠加就表现为"忽有忽无"。
既然老师要动画,方向改为"保留库自带的过渡,把起点钉死":

  库每切一张新建 <img>,刚插入时用默认 CSS(满宽、贴顶);加载完成后才写内联样式:先写成
  "屏幕中心的一个点",再写最终尺寸,靠 CSS 过渡补间。过渡起点 = 浏览器上一次渲染过的样式,
  哪一帧被渲染由缓存/网速/主线程决定 —— 渲染到"满宽贴顶"那帧就会从上方飞入。

  对策(不动库任何逻辑):用 :not([style]) 只命中"还没有内联样式"的那一瞬,把这一瞬做成
  屏幕中心的小点。库一写内联样式该规则立即失效,因此绝不影响最终显示,也不会与库打架;
  过渡仍是库自带的,所以动画完整保留。

本次移除上一版"临时关 options.transition"的整段补丁。
本机验收:9 套 jsdom 守卫测试全绿;浏览器六项全过(拖动跟手+回弹、横划 120px 切、
竖划不切、中途不翻页、捏合 351→1615、双击 1615→2751、切图后不误关);
飞入专项按 Claude 的延迟加载法执行;最终显示核对(宽度非 1px、opacity=1、已写内联样式)。
注:本机模拟环境复现不出真机那个飞入,最终以真机为准。
过程记录:本轮先被守卫拦下一次(把 CSS 误放进 <script> 块,测试会把 script 内容当 JS 跑),
已挪到 <style> 块并重跑,未影响线上。
2026-10-03 15:28:51 +08:00

434 lines
32 KiB
Docker
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---------------------------------------------------------------------------
# Lsky Pro Docker 镜像 —— mole404/lsky-pro-docker
#
# 上游应用:https://github.com/lsky-org/lsky-pro (GPL-3.0,官方已停止维护)
# 上游镜像:https://github.com/HalcyonAzure/lsky-pro-docker (AGPL-3.0,本仓库由它衍生)
#
# 本仓库 = 上游应用源码快照(vendored 在 src/)+ Docker 构建脚本 + 少量前端补丁。
#
# 与上游镜像的差异(详见 README):
# 1. 应用源码**已 vendored 进本仓库**(src/ = 上游 commit 38d52c46… 即 2025-11-24 的 master 完整树)。
# 构建不再联网拉 GitHub archive:上游已明示停止维护,万一仓库被删/归档/改名也还能重建镜像;
# 之后要改 PHP 代码,直接改 src/ 下的文件、提交即可,不用再维护 overlay 叠加。
# 2. 前端补丁已就地落在 src/ 里(iOS Safari 长按弹菜单 + 菜单交互修复)。
# "我们相对上游改了什么"见 patches/ios-longpress.patch,可用 tools/diff-vs-upstream.sh 重新生成/核对。
# 3. 基础镜像显式写成 Debian bookworm 变体(与 2024-04 那版线上镜像同一 Debian 大版本)
# 4. install-php-extensions 钉到具体版本,不再用 latest
# 5. 构建期自证:① 我们从不修改的上游文件按上游 md5 校验(快照没被动过)
# ② 补丁产物与「有意偏离上游」的 composer.lock 按预期 md5 校验(改了不更新就构建失败)
# 6. 运行时加固(F23,2026-09-30):PHP 关掉 expose_php / display_errors
# (/usr/local/etc/php/conf.d/zz-lsky-hardening.ini)、Apache 关掉版本号外泄
# (/etc/apache2/conf-enabled/zz-lsky-hardening.conf:ServerTokens Prod + ServerSignature Off),
# 两处都带构建期断言;CI 的「Verify published image」再对真实响应头断言一次。
# 7. 认证类 POST 加路由级节流(src/routes/auth.php:login/register/forgot-password/confirm-password)
# 8. 测试版内存收紧(2026-10-01):PHP memory_limit 收到 64M(可运行时用 env PHP_MEMORY_LIMIT
# 覆盖,entrypoint 渲染进上面的 hardening ini)、opcache memory_consumption 128→64、
# Apache MaxConnectionsPerChild 保持默认 5(低流量站,每 ~3 分钟换班开销可忽略,
# 取最严的内存纪律;可运行时用 env APACHE_MAX_CONNECTIONS_PER_CHILD 覆盖,0 = 永不回收)。
# 详见各段注释与 CI「Verify published image」的新断言。
#
# 构建:docker build -t lsky-pro-docker .
# ---------------------------------------------------------------------------
# 上游源码快照的版本号(只用于版本标记与镜像元数据;代码本体是仓库里的 src/)。
# 改这里 = 必须同时用 tools/vendor-upstream.sh 重新 vendor src/,否则标记与实际代码不符(自证 1 会失败)。
ARG LSKY_COMMIT=38d52c4609eb85236b45ac75acac2ced55174953
# PHP 版本:8.1 已于 2025-12-31 EOL,8.2 的 EOL 是 2026-12-31(都太近),
# 所以选 8.3(安全维护到 2027-12-31)。依赖侧配套升到同一大版本线内的最新:
# laravel/framework 9.52.22(9.x 最后一个补丁)+ symfony/* 6.4 LTS(Laravel 9 的 ^6.0 正好允许)。
# 要回退 PHP:改这个数字即可,但请连镜像 tag 一起回退(新 lock 里的包可能要求 ≥8.3)。
ARG PHP_VERSION=8.3
ARG DEBIAN_RELEASE=bookworm
ARG PHP_EXT_INSTALLER_VERSION=2.12.0
# ---------------------------------------------------------------------------
# F15:基镜像钉 digest —— 上游重推 tag(同 tag 指向新内容)时,构建出来的仍是同一份东西。
# 两个阶段用的**不是**同一个基镜像(builder = cli,runtime = apache),各自钉自己的。
# 更新方法(改 PHP_VERSION / DEBIAN_RELEASE 时必须一并更新,否则构建会因 manifest 不匹配而失败):
# 1) 取 token(本机没有 docker,别用 docker manifest):
# curl -s 'https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/php:pull'
# -> 取返回 JSON 里的 .token
# 2) 读 digest(tag 换成要查的那个):
# curl -sI -H "Authorization: Bearer $TOKEN"
# -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
# https://registry-1.docker.io/v2/library/php/manifests/8.3-cli-bookworm
# -> 看响应头 Docker-Content-Digest(8.3-apache-bookworm 同理,两个都要查)
# 3) 把拿到的 sha256 填进下面的 ARG(**tag 保留**,digest 附在 @ 之后,形如 php:8.3-apache-bookworm@sha256:…)
# 注意:钉死后上游的安全修复**不会**自己进来 —— 想拿新修复就得重跑上面两步、更新 digest,再重建镜像。
# 试构建(CI 的 workflow_dispatch 换 PHP 版本)也要一并覆盖对应的 digest ARG:
# 带 `@digest` 时 **digest 优先、tag 只作装饰**,只换 PHP_VERSION 不换 digest 会静默构建出旧版本。
# CI 已自动化:build-image.yaml 的 Resolve 步骤会按请求的 PHP 版本解析出对应 digest 并覆盖这两个 ARG
# (也可在 workflow_dispatch 里手工传 php_cli_digest / php_apache_digest)。上面这套手工方法仅用于本地/离线场景。
# 实测(2026-09-30,Docker Hub Registry API 原始响应头,见提交说明/报告):
# php:8.3-cli-bookworm -> sha256:4687aec76c4a895b68b91bcd5e48f1ba7a3dea120f6de50bba7e1a93af5372dd
# php:8.3-apache-bookworm -> sha256:06df07e2e1d72581dde4fd97bc98d2d0b78686fc13d6a041c520da9a6ce73055
ARG PHP_CLI_IMAGE_DIGEST=sha256:4687aec76c4a895b68b91bcd5e48f1ba7a3dea120f6de50bba7e1a93af5372dd
ARG PHP_APACHE_IMAGE_DIGEST=sha256:06df07e2e1d72581dde4fd97bc98d2d0b78686fc13d6a041c520da9a6ce73055
FROM php:${PHP_VERSION}-cli-${DEBIAN_RELEASE}@${PHP_CLI_IMAGE_DIGEST} AS build
ARG LSKY_COMMIT
ARG FORK_SHA=unknown
WORKDIR /build
# 安装必要的依赖
# curl —— 装 composer
# unzip —— composer 解压 dist 包要用(去掉它会导致 composer install 直接失败:CI 实测踩过)
# git —— 部分包会走 source 安装时的兜底
RUN docker-php-ext-install ftp
# 依赖安装(builder 阶段的平台要求必须满足,否则 composer install 直接失败)
# F16:composer 安装器**先校验、再执行**(不能像以前那样 `curl … | php` 直接把网上的字节喂给解释器)。
# 官方做法(2026-09-30 实查 https://getcomposer.org/download/):预期 sha384 来自
# https://composer.github.io/installer.sig,与 https://getcomposer.org/installer 分属两个域名,
# 单一站点被投毒时另一侧能拦住。校验不过(sig 拉不到 / 格式不对 / 对不上)就不往下走 = 构建失败。
# 这里**故意不写死**哈希:installer 会随每个 composer 版本重新生成(官方文档明确警告别复制传播这段代码),
# 写死的话 composer 一发新版、构建就必然红。想连 composer 版本一起钉,改用带版本号的
# https://getcomposer.org/download/<version>/composer.phar + 同目录 .sha256sum 校验。
RUN apt-get update && \
apt-get install -y curl unzip git && \
curl -fsSL -o /tmp/composer-setup.php https://getcomposer.org/installer && \
curl -fsSL -o /tmp/composer-setup.sig https://composer.github.io/installer.sig && \
grep -Eq '^[0-9a-f]{96}$' /tmp/composer-setup.sig && \
printf '%s %s\n' "$(cat /tmp/composer-setup.sig)" /tmp/composer-setup.php | sha384sum -c - && \
php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer && \
rm -f /tmp/composer-setup.php /tmp/composer-setup.sig && \
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# 应用源码:本仓库 src/ 里的 vendored 快照(= 上游那个 commit 的完整树,304 个文件)。
# 前端补丁已经就地在 src/ 里改好,这里不做任何"叠加覆盖"。改代码 = 改 src/ 然后提交。
COPY src/ /build/
# 自证 1:vendored 快照确实是上游那一版。
# 下面这个文件我们从不修改,md5 等于上游原值;对不上就说明 src/ 被误改
# (或与 LSKY_COMMIT 不符)→ 直接构建失败,不产出不可信的镜像。
RUN printf '%s\n' \
'3b58bff18126a61c142cc576457c82a6 ./public/index.php' \
| md5sum -c -
# 自证 1b:有意偏离上游、但仍需锁死的文件。
# config/convention.php 与 routes/web.php 被本 fork 改过 —— 移除了「画廊」「系统升级」,
# 且首页 / 在登录状态下改为跳转「我的图片」(游客上传页只留给未登录用户)
# 两个功能(删配置项常量、删路由表条目,详见 README)。
# app/Services/ImageService.php 同样是有意偏离的:移除 SVG 支持时删掉了几个「只可能因为
# svg 数据而命中」的分支(上传跳过图片处理 / 跳过违规扫描的 in_array svg、makeThumbnail
# 里 svg 直拷原文件),详见 README「与上游的差异」第 7 条。
# routes/auth.php 也是有意偏离(F23 防爆破):给 login / register / forgot-password /
# confirm-password 四个 POST 端点加了 throttle(详见文件内注释)。它同样被钉住,
# 改了不更新这行 md5 就构建失败。
# 这几个文件以后只要被改动(哪怕手滑),构建就会红 —— md5 必须随改动同步更新。
RUN printf '%s\n' \
'8136e73b50315d783f105dd4ac9971bb ./config/convention.php' \
'881fdbaed19ef39027783f093448875d ./routes/web.php' \
'c1ab546f3e7f5237c1d45435171858ce ./routes/auth.php' \
'e7edc0fc9dc8c654c4debca0e1d2eac4 ./app/Services/ImageService.php' \
| md5sum -c -
# 有意偏离上游的另一个文件:composer.lock。
# 上游冻结在 2024-12 的解析结果(laravel 9.52.17 / symfony 6.0.x —— 两者都已 EOL),
# 我们把它升到「同一大版本线内的安全版本」:
# laravel/framework 9.52.22(9.x 最后一个补丁;9.52.21 -> 9.52.22 是一次安全修复)、symfony/* 6.4 LTS(Laravel 9 的 ^6.0 允许)、
# guzzlehttp/guzzle 7.15.5、guzzlehttp/psr7 2.13.1、phpseclib 3.0.57、
# league/commonmark 2.10.3、aws/aws-sdk-php 3.398.1、laminas-diactoros 2.26.0
# psy/psysh 0.11.23(2026-09-30:原 0.11.12 有 GHSA-4486-gxhx-5mg7 —— CWD 下 .psysh.php 自动加载
# 导致本地提权;上游在同一条 0.11 线发了修复,随 laravel/tinker 一起进镜像,故一并升掉。
# osv-scanner.toml 里对应那条白名单已随之删除=扫描真的干净,而不是被豁免。)
# —— 修掉了除「Laravel 9 框架自身那几条(9.x 线没有修复版本)」以外的已知公告。
# 所以它不再等于上游值 —— 但仍钉 md5:任何改动都必须同步更新这里(防止有人别处悄悄改)。
RUN printf '%s\n' \
'12ab233331630b2dd1c48cee9e5ce927 ./composer.lock' \
| md5sum -c -
# --no-dev:镜像只装运行时依赖。开发包(debugbar / ignition / whoops / phpunit / faker / sail…)
# 对线上没有用途,留着既是体积也是暴露面(debugbar 那类在调试模式下会漏内部信息)。
# 连带项:卷里的 bootstrap/cache/packages.php 是"当年装着开发包"时生成的,里面记着这些包的
# ServiceProvider 类名 —— 包没了之后,Laravel 引导时加载该清单会 Class not found → 整站 500。
# 所以 entrypoint 的同步分支会作废这份清单(连同 services.php),CI 里也有对应的回归断言。
RUN php -r "file_exists('.env') || copy('.env.example', '.env');" \
&& composer install --no-dev
# ---------------------------------------------------------------------------
# fork 补丁:iOS 长按菜单修复 + 菜单交互修复(已就地在 src/ 里,这里只做校验)
#
# 补丁涉及三个文件(两份 JS 内容必须一致,别只改一份):
# src/resources/js/context-js.js —— 可读源码
# src/public/js/context-js/context-js.js —— 浏览器实际加载的那份
# src/resources/views/user/images.blade.php —— 引入脚本那一行(资源版本串)
# (上游 webpack.mix.js 里本来也是 mix.copy('resources/js/context-js.js', 'public/js/context-js'),
# 只是仓库里 public/ 下那份是旧工具链留下的压缩产物,一直没跟着源码更新。)
# ---------------------------------------------------------------------------
# 自证 2:补丁内容与预期完全一致,blade 的资源版本串也在。
# 改 src/ 里这三个文件后必须同步更新这里的 md5(故意做成"改了不更新就构建失败"),
# 并重新生成 patches/ios-longpress.patch(tools/diff-vs-upstream.sh)。
RUN printf '%s\n' \
'c0e513ec8e93fd81b34b3c6de5cf5eb8 ./public/js/context-js/context-js.js' \
'c0e513ec8e93fd81b34b3c6de5cf5eb8 ./resources/js/context-js.js' \
'463c8b7f163b7f6c6ea88b0c794a44f9 ./resources/views/user/images.blade.php' \
| md5sum -c - \
&& grep -q "assetVersion('js/context-js/context-js.js')" ./resources/views/user/images.blade.php \
&& grep -q 'isIOSWebKit' ./public/js/context-js/context-js.js \
&& grep -q 'LONG_PRESS_DELAY = 250' ./public/js/context-js/context-js.js \
&& grep -q 'LONG_PRESS_DELAY = 250' ./resources/js/context-js.js \
&& grep -q 'installMenuGuard' ./public/js/context-js/context-js.js \
&& grep -q 'MENU_OPEN_GRACE' ./public/js/context-js/context-js.js \
&& grep -q 'touch-open' ./public/js/context-js/context-js.js \
&& grep -q 'fitSubmenu' ./public/js/context-js/context-js.js \
&& grep -q 'clampMenu' ./resources/js/context-js.js \
&& grep -q 'submenu-inplace' ./public/js/context-js/context-js.js \
&& grep -q 'SUBMENU_GUARD' ./public/js/context-js/context-js.js
# 代码版本标记:入口脚本用它判断「卷里的代码是不是当前镜像这一版」,不一致才同步(见 entrypoint.sh)。
# 它由「源码 commit + 补丁 md5 + 内容指纹」组成:任何代码/补丁变化都会让它变。
# 改 src/ 下任何文件(哪怕一行)后,都要把对应的 md5 一并更新,否则容器不会重新同步进卷。
#
# 内容指纹(F22):上面那两个 md5 只覆盖「我们打补丁的那几个文件」—— app.js / PHP 这类**没被钉 md5**
# 的文件改了之后标记不变,卷里的旧代码就永远不会被同步(镜像与卷不同步,且没有任何报错)。
# 所以这里再对三处构建期内容做确定性哈希,把它们也编进标记:
# app_src_md5 = app/ config/ routes/ 三个目录下所有文件「按路径排序后内容拼接」的 md5
# (排序用 sort -z,与文件系统返回顺序无关 = 同一份源码在任何机器上结果一致);
# app_js_md5 = public/js/app.js 的 md5;app_css_md5 = public/css/app.css 的 md5;
# composer_lock_md5 = composer.lock 的 md5(应用根目录,构建后镜像里是 /var/www/lsky/composer.lock)。
# 补这一条的背景:上面的指纹只覆盖「代码」,而 composer.lock 变了(只升级 vendor 依赖、
# 其它 PHP/前端都没动)时,标记不变 → 老卷不会重新同步 → 卷里是旧依赖、镜像里是新依赖。
# composer install 是在 build 阶段、镜像自带的 vendor/ 已经按新 lock 装好;
# 加了这一条,只换依赖的镜像也会让标记变、触发一次同步。
# 任何一个不存在 / 命令失败都写 none(标记照写)—— **绝不能让构建失败**。
# 入口脚本比的是整个标记文件的内容,所以多出这几行 = 换镜像时自动触发同步,entrypoint 不需要改。
RUN APP_SRC_MD5=$(find app config routes -type f -print0 2>/dev/null | sort -z | xargs -0 -r cat 2>/dev/null | md5sum | cut -d' ' -f1); \
[ -n "$APP_SRC_MD5" ] || APP_SRC_MD5=none; \
APP_JS_MD5=$(md5sum public/js/app.js 2>/dev/null | cut -d' ' -f1); \
[ -n "$APP_JS_MD5" ] || APP_JS_MD5=none; \
APP_CSS_MD5=$(md5sum public/css/app.css 2>/dev/null | cut -d' ' -f1); \
[ -n "$APP_CSS_MD5" ] || APP_CSS_MD5=none; \
COMPOSER_LOCK_MD5=$(md5sum composer.lock 2>/dev/null | cut -d' ' -f1); \
[ -n "$COMPOSER_LOCK_MD5" ] || COMPOSER_LOCK_MD5=none; \
printf '%s\n' \
"fork_sha=${FORK_SHA}" \
"lsky_commit=${LSKY_COMMIT}" \
"context_js_md5=c0e513ec8e93fd81b34b3c6de5cf5eb8" \
"images_blade_md5=463c8b7f163b7f6c6ea88b0c794a44f9" \
"app_src_md5=${APP_SRC_MD5}" \
"app_js_md5=${APP_JS_MD5}" \
"app_css_md5=${APP_CSS_MD5}" \
"composer_lock_md5=${COMPOSER_LOCK_MD5}" \
> .code-revision \
&& cat .code-revision
FROM php:${PHP_VERSION}-apache-${DEBIAN_RELEASE}@${PHP_APACHE_IMAGE_DIGEST}
ARG LSKY_COMMIT
ARG PHP_EXT_INSTALLER_VERSION
ARG FORK_SHA=unknown
LABEL org.opencontainers.image.source="https://github.com/mole404/lsky-pro-docker" \
org.opencontainers.image.title="lsky-pro-docker (ios-longpress + menu-ux)" \
org.opencontainers.image.description="Lsky Pro 图床的 Docker 镜像;应用源码 vendored 取自 lsky-org/lsky-pro@${LSKY_COMMIT}(构建不再依赖上游在线),并在其上做了 iOS 长按菜单修复与菜单交互修复(点菜单外只关菜单、触摸端二级菜单点击展开、滚动/缩放/Esc 关闭)" \
org.opencontainers.image.licenses="AGPL-3.0" \
org.opencontainers.image.revision="${FORK_SHA}" \
lsky.source.commit="${LSKY_COMMIT}"
# 如果构建速度慢可以换源
# RUN sed -i -E "s@http://***@http://mirrors.cloud.tencent.com@g" /etc/apt/sources.list
# 安装相关拓展(钉到具体版本,不使用 latest)
# F16:mlocati 的 release **只有脚本本身、没有官方校验资产**(2026-09-30 实查 2.12.0 的 release assets
# 只有 1 个),所以期望 sha256 直接写死在仓库里,下载后立即校验,验不过就构建失败。
# 换 PHP_EXT_INSTALLER_VERSION 时**必须**同步更新下面这个值(故意的:忘了就红):
# 新值取法:curl -fsSL -o /tmp/ipe "https://github.com/mlocati/docker-php-extension-installer/releases/download/<新版本>/install-php-extensions" && sha256sum /tmp/ipe
# 当前值对应 2.12.0(2026-09-30 实测):3f49c71fa66c79b8b2b96bc0ce92885dafd7946f3b5a46f545b390d6f1617e2c
ARG PHP_EXT_INSTALLER_SHA256=3f49c71fa66c79b8b2b96bc0ce92885dafd7946f3b5a46f545b390d6f1617e2c
RUN curl -fsSL -o /usr/local/bin/install-php-extensions \
"https://github.com/mlocati/docker-php-extension-installer/releases/download/${PHP_EXT_INSTALLER_VERSION}/install-php-extensions" \
&& echo "${PHP_EXT_INSTALLER_SHA256} /usr/local/bin/install-php-extensions" | sha256sum -c -
# 开启SSL
RUN a2enmod ssl && a2ensite default-ssl
# F21 修正(CI 实撞,2026-09-30):Debian 自带的 /etc/apache2/sites-available/default-ssl.conf
# 由上面的 a2ensite 启用,而它**仍指向被删掉的那对 snakeoil 证书文件** → Apache 启动时配置校验报
# AH00526: SSLCertificateFile: file '/etc/ssl/certs/ssl-cert-snakeoil.pem' does not exist or is empty
# 整个 Apache 起不来(症状很难认:容器进程在、安装页返回 000)。
# 把它也指到入口脚本运行期生成的那对证书(路径固定,与 000-default.conf.template 的 HTTPS vhost 一致)。
# 护栏按**文件路径**判而不是按 "snakeoil" 这个词判 —— 该文件里有一句提到 snakeoil 的注释是正常的。
# (本地在真容器里模拟过:改完 + 证书就位 → apache2ctl -t 输出 Syntax OK)
RUN sed -i \
-e 's#/etc/ssl/certs/ssl-cert-snakeoil.pem#/etc/apache2/ssl/lsky-selfsigned.crt#' \
-e 's#/etc/ssl/private/ssl-cert-snakeoil.key#/etc/apache2/ssl/lsky-selfsigned.key#' \
/etc/apache2/sites-available/default-ssl.conf \
&& ! grep -rn '/etc/ssl/certs/ssl-cert-snakeoil\|/etc/ssl/private/ssl-cert-snakeoil' /etc/apache2/ \
&& grep -q 'lsky-selfsigned.crt' /etc/apache2/sites-available/default-ssl.conf
# ---------------------------------------------------------------------------
# F23:Apache 版本号外泄 —— 关掉 Server 头里的精确版本与页脚签名。
# 实测(2026-09-30,本机 docker run 已发布镜像):响应头 `Server: Apache/2.4.68 (Debian)`。
# (同一次探测还看到 `X-Powered-By: PHP/8.3.35` —— 那条由下面的 PHP ini 加固负责。)
# ServerTokens Prod → Server 头只发 "Apache"(不带版本 / 模块 / OS);
# ServerSignature Off → 错误页与目录列表不再追加 "Apache/2.4.68 (Debian) Server at …" 页脚
# (Debian 出厂默认本就是 Off,这里显式写死:基镜像换版本时默认值可能变)。
# 为什么放 conf-enabled 的独立文件、而不是 000-default.conf.template 那个 vhost 模板:
# ① ServerTokens 是**全局(main server config)指令**,写进 <VirtualHost> 里 Apache 会直接
# 拒绝加载该配置("ServerTokens not allowed in <VirtualHost> context")→ 整站起不来;
# 它必须落在全局上下文,而模板整份就是 vhost。
# ② 那个模板同时管 HTTPS vhost、目录权限等一堆东西,改它等于改 vhost 行为,风险面远大于
# 「加一个 conf-enabled 文件」。
# ③ apache2.conf 先 IncludeOptional mods-enabled/ 再 IncludeOptional conf-enabled/,入口
# 脚本写 mpm.conf 也走这里;conf-enabled 不会被入口脚本覆写,换镜像照样生效。
# 构建期自证:① 文件真的在;② 两行**逐行精确**在里面(grep -qx,不是模糊包含)。
# 语法由真容器自证(CI 的 smoke 步骤会起容器跑 Apache,配置写错站点就起不来)。
# ---------------------------------------------------------------------------
RUN printf '%s\n' \
'# fork 加固(F23):不对外泄露 Apache 精确版本' \
'ServerTokens Prod' \
'ServerSignature Off' \
> /etc/apache2/conf-enabled/zz-lsky-hardening.conf \
&& test -f /etc/apache2/conf-enabled/zz-lsky-hardening.conf \
&& grep -qx 'ServerTokens Prod' /etc/apache2/conf-enabled/zz-lsky-hardening.conf \
&& grep -qx 'ServerSignature Off' /etc/apache2/conf-enabled/zz-lsky-hardening.conf
# ftp 必须**显式**装进运行时镜像:官方 php:8.1 镜像自带 ftp,php:8.3 的没有
# (2026-09-28 换 8.3 时 CI 实测:builder 阶段 composer install 直接失败,
# 报 league/flysystem-ftp requires ext-ftp)。它是 Lsky 的 FTP 存储驱动要用的扩展,
# 不能依赖"从基镜像继承"。CI 里也加了 ftp 的硬断言。
#
# opcache(测试版收紧,先量后改):原值 memory_consumption=128 / max_accelerated_files=10000。
# 实测(2026-10-01,本机真容器 Apache 进程内 opcache_get_status):
# · 真实负载(安装页 + 登录/注册/首页等通跑一轮)预热集合 = 827 个脚本 / 已用 25.0MB;
# · 全树编译(把 /var/www/lsky 下 10842 个 .php 全部塞进缓存)上限 = 9949 个脚本 / 255.9MB
# (在 256MB 上限处被截断 —— 这是"理论上限",没有任何单次请求会走到)。
# 真实使用约 25MB,故 memory_consumption 收到 64(≈实测 2.6 倍,且不低于 64):
# 够用又有余量,比原来的 128 省 64MB 共享内存(本机 965MB,内存是瓶颈)。
# max_accelerated_files 保持 10000 —— 已远超实测预热集合(827)的 12 倍;哈希表本身只占 KB 级,
# 调低没有收益、调高没有意义(64MB 的脚本缓存也装不下全树)。这组值下 opcache 不会因容量不足频繁重启。
# validate_timestamps 保持 1(显式写死,防基镜像默认变化):单机热更新靠它 —— 卷里代码一改,
# 带 mtime 校验的请求就会重新编译;关掉它换镜像后要重启容器才生效。
RUN apt-get update && \
apt-get install -y gettext && \
apt-get clean && rm -rf /var/cache/apt/* && rm -rf /var/lib/apt/lists/* && rm -rf /tmp/* && \
a2enmod rewrite && chmod +x /usr/local/bin/install-php-extensions && \
install-php-extensions imagick bcmath pdo_mysql pdo_pgsql redis ftp && \
\
{ \
echo 'post_max_size = 100M;';\
echo 'upload_max_filesize = 100M;';\
echo 'max_execution_time = 600S;';\
} > /usr/local/etc/php/conf.d/docker-php-upload.ini; \
\
{ \
echo 'opcache.enable=1'; \
echo 'opcache.interned_strings_buffer=8'; \
echo 'opcache.max_accelerated_files=10000'; \
echo 'opcache.memory_consumption=64'; \
echo 'opcache.save_comments=1'; \
echo 'opcache.revalidate_freq=1'; \
echo 'opcache.validate_timestamps=1'; \
} > /usr/local/etc/php/conf.d/opcache-recommended.ini; \
\
echo 'apc.enable_cli=1' >> /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini; \
\
echo 'memory_limit=512M' > /usr/local/etc/php/conf.d/memory-limit.ini; \
\
mkdir /var/www/data; \
chown -R www-data:root /var/www; \
chmod -R g=u /var/www
# ---------------------------------------------------------------------------
# F23:PHP ini 加固 —— 关掉「版本号外泄」与「错误直出」。
# 实测(2026-09-30,本机 docker run 已发布镜像):响应头带 `X-Powered-By: PHP/8.3.35`。
# 官方 php 镜像**不带 php.ini**(只有 php.ini-production/-development 两个样本),于是
# PHP 编译期的默认值直接生效:expose_php=1、display_errors=1。
# expose_php=0 → 不再发 X-Powered-By(少给扫描器一条精确版本情报);
# display_errors=0 → 运行期错误不往 HTTP 响应里吐(Laravel 自己的错误处理/日志不受影响)。
# 文件名用 zz- 前缀:conf.d 里的 ini 按**文件名 ASCII 序**加载、后加载的覆盖先前的,
# zz- 排最后 = 以后基镜像或扩展安装器再加什么 ini,都盖不掉这两项(顺序就是这里的关键)。
# 本次(测试版)追加 memory_limit=64M:先读现状 —— PHP 编译期默认 128M,而镜像里
# (上游)memory-limit.ini 写的是 512M,所以**原来的生效值是 512M**。把 64M 放进这份
# zz- 文件即可生效(排最后、覆盖 memory-limit.ini),不必去改那份上游 ini。
# 默认 64M 而不是写死:entrypoint.sh 运行期会把 PHP_MEMORY_LIMIT(默认 64M)渲染进这一项,
# 在 compose 里改一个 env 就能回 128M/256M、不用重建镜像(脏值只忽略该项并警告)。
# 其余 ini 未动:upload(100M 上传上限)保持原样;opcache-recommended.ini 的
# memory_consumption 本次从 128 收到 64(依据见上面 opcache 段的实测注释)。
# 构建期自证:用 php -r **真读回 ini_get()** 断言已是目标值 —— 不是 grep 配置文件里有没有那几行
# (那只证明"文件写了",证明不了"PHP 真的读到了")。expose_php/display_errors 判定用
# filter_var(FILTER_VALIDATE_BOOL),兼容 Off / false / 0 / 空串 几种写法;memory_limit 直接比对
# 字符串 "64M"。CLI 与 Apache 模块用的是同一份 /usr/local/etc/php/conf.d。
# ---------------------------------------------------------------------------
RUN printf '%s\n' \
'; fork 加固(F23 + 测试版):不泄露版本号 / 错误不直出 / 收紧内存上限' \
'expose_php=0' \
'display_errors=0' \
'memory_limit=64M' \
> /usr/local/etc/php/conf.d/zz-lsky-hardening.ini \
&& test -f /usr/local/etc/php/conf.d/zz-lsky-hardening.ini \
&& php -r 'foreach (["expose_php", "display_errors"] as $k) { if (filter_var(ini_get($k), FILTER_VALIDATE_BOOL)) { fwrite(STDERR, "ini 加固自证失败:".$k." 仍开着 (".var_export(ini_get($k), true).")".PHP_EOL); exit(1); } echo $k."=".var_export(ini_get($k), true)." 已关闭".PHP_EOL; }' \
&& php -r '$m = ini_get("memory_limit"); if ($m !== "64M") { fwrite(STDERR, "memory_limit 自证失败:读回 ".var_export($m, true).",期望 64M(zz-lsky-hardening.ini 是否被别的 ini 覆盖?)".PHP_EOL); exit(1); } echo "memory_limit=".$m."(entrypoint 可用 PHP_MEMORY_LIMIT 运行期覆盖)".PHP_EOL;'
# F21:不再随镜像发货证书。
# 原来这里是 `COPY ./ssl /etc/ssl` —— 把仓库里那对 Debian snakeoil 证书(ssl/certs + ssl/private,
# 在 GitHub 上人人可见)拷进镜像,模板的 HTTPS vhost 直接引用它 = 每个使用者共用同一份公开私钥。
# 现在改成运行期自签:入口脚本首次启动时用 openssl 生成到 /etc/apache2/ssl/(模板指向那里),
# 所以镜像里**不再有任何证书/私钥文件**。openssl 必须存在 —— 由下面的护栏钉住。
COPY --from=build /build /var/www/lsky/
COPY ./000-default.conf.template /etc/apache2/sites-enabled/
COPY ./ports.conf.template /etc/apache2/
# Apache MPM 模板:入口脚本会把它(按 APACHE_* 渲染后)写到 /etc/apache2/conf-enabled/mpm.conf,
# 覆盖 mods-enabled 里 mpm_prefork.conf 的出厂值(apache2.conf 先 include mods-enabled 再 include conf-enabled)。
COPY ./mpm.conf.template /etc/apache2/
COPY entrypoint.sh /
# 数据安全护栏:强制同步的排除清单缺任何一项都可能覆盖站点数据/配置,缺了就构建失败。
# (镜像自带入口脚本运行时才会执行,这里只做静态断言,保证清单不被误删。)
RUN grep -q -- '--exclude=.\/.env' /entrypoint.sh \
&& grep -q -- '--exclude=.\/storage' /entrypoint.sh \
&& grep -q -- '--exclude=.\/database' /entrypoint.sh \
&& grep -q -- '--exclude=.\/bootstrap\/cache' /entrypoint.sh \
&& grep -q -- '--exclude=.\/public\/i' /entrypoint.sh \
&& grep -q 'views/\*.php' /entrypoint.sh \
&& grep -q 'code-revision' /entrypoint.sh \
&& test -f /var/www/lsky/.code-revision \
&& grep -q '^fork_sha=' /var/www/lsky/.code-revision \
&& grep -q '^composer_lock_md5=' /var/www/lsky/.code-revision \
&& grep -q 'tar cf - --exclude=./.env --exclude=./.code-revision' /entrypoint.sh \
&& grep -q 'cp -a "$IMAGE_MARKER" "$VOLUME_MARKER"' /entrypoint.sh
# MPM 模板护栏:模板必须真的进镜像、入口脚本必须真的往 conf-enabled 写(漏了 COPY 或改了目标路径就构建失败)。
# 这里只做静态断言;渲染/覆盖是否生效由 CI 里的真容器自证(Verify Apache MPM defaults and overrides)。
RUN test -f /etc/apache2/mpm.conf.template \
&& test -d /etc/apache2/conf-enabled \
&& grep -q 'conf-enabled/mpm.conf' /entrypoint.sh \
&& grep -q 'APACHE_START_SERVERS' /etc/apache2/mpm.conf.template \
&& grep -q 'APACHE_MIN_SPARE_SERVERS' /etc/apache2/mpm.conf.template \
&& grep -q 'APACHE_MAX_SPARE_SERVERS' /etc/apache2/mpm.conf.template \
&& grep -q 'APACHE_MAX_REQUEST_WORKERS' /etc/apache2/mpm.conf.template \
&& grep -q 'APACHE_MAX_CONNECTIONS_PER_CHILD' /etc/apache2/mpm.conf.template \
&& grep -q 'APACHE_KEEP_ALIVE' /etc/apache2/mpm.conf.template
# F10 护栏:健康检查要用的 curl 在运行镜像里真的存在(php:*-apache 自带,但别依赖「我记得它自带」)。
RUN command -v curl > /dev/null
# F21 护栏:运行期自签 HTTPS 证书要用 openssl(php:*-apache 基于 debian:bookworm,自带 openssl;
# 但别依赖「我记得它自带」)。缺了它入口脚本只能打警告,HTTPS vhost 起不来。
RUN command -v openssl > /dev/null
WORKDIR /var/www/html/
VOLUME /var/www/html
ENV WEB_PORT=8089
ENV HTTPS_PORT=8088
EXPOSE ${WEB_PORT}
EXPOSE ${HTTPS_PORT}
# F10:健康检查。原来没有 —— 「Apache 进程还在」不代表站点还活着(卷同步失败的容器进程照样在)。
# 探本机 http://127.0.0.1:${WEB_PORT}/:shell 形式下 ${WEB_PORT} 是**运行期**由 ENV 展开的,
# 所以 compose/`-e WEB_PORT=xxxx` 换了端口也照样探对端口(不用改这里)。
# curl -f:HTTP 状态 ≥400 或连不上都算失败(非 0 退出 = unhealthy);302/200 都算健康
# —— 未安装状态下 / 会 302 去安装页,登录后首页也可能 302,这两种都不该被判定为病态。
# start-period=90s:入口脚本要渲染 Apache/MPM 配置再把应用播种进卷(CI 里最长等到 60s),
# 这段时间内的失败不计入 retries,避免容器刚起来就 HEALTING→unhealthy。
# 依赖 curl 在运行镜像里必然存在(php:*-apache 官方镜像自带;下面的护栏会钉住这一点)。
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -fsS -o /dev/null "http://127.0.0.1:${WEB_PORT}/"
RUN chmod a+x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["apachectl","-D","FOREGROUND"]