Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9060872
Fix PHP-FPM unix socket broken by php:8.2-fpm base image rename
edwh May 12, 2026
a901143
Fix emoji/4-byte UTF-8 corruption in fly-migrate.sh
edwh May 12, 2026
524c5d7
Add (notifiable_type, notifiable_id, created_at) index to notifications
edwh May 12, 2026
8621d0d
Cap notifications queries to 1 year, fix count/limit in navbar and API
edwh May 12, 2026
f843473
Scale to performance-2x, tune MySQL buffer pool, fix Blade @php navba…
edwh May 12, 2026
52adc32
Fix stored-function import error: set log_bin_trust_function_creators
edwh May 12, 2026
16c9168
Add procps to restarters runtime image (vmstat, top, ps for live debu…
edwh May 12, 2026
c10a328
Enable wiki integration on Fly (FEATURE__WIKI_INTEGRATION=true)
edwh May 12, 2026
63e9790
Enable Discourse integration on Fly (FEATURE__DISCOURSE_INTEGRATION=t…
edwh May 12, 2026
5fd55dc
Raise PHP max_execution_time to 120s (was 30s default)
edwh May 12, 2026
d43a957
Fix NetworkController: store logo to correct disk and generate _x100 …
edwh May 12, 2026
9302ca2
Replace full-table PHP aggregates with SQL in homepage_data and devic…
edwh May 12, 2026
41e94e8
Add pcntl PHP extension to runtime image (required by Discourse liste…
edwh May 12, 2026
e315a14
Fix Wiki API: update namespace from mediawiki-api v0.7.x to v3.x
edwh May 12, 2026
8555555
Fix WordPress XMLRPC endpoint: use standard xmlrpc.php
edwh May 12, 2026
05e092a
Fix cross-origin embedding: remove X-Frame-Options for /outbound/, ad…
edwh May 12, 2026
3618874
Fix wiki CookieJar for Guzzle 7 and correct WP XMLRPC endpoint
edwh May 12, 2026
891bec5
Guard pcntl_signal/pcntl_alarm with function_exists for Fly.io
edwh May 12, 2026
0835233
Remove broken pcntl timeout from AddUserToDiscourseThreadForEvent
edwh May 12, 2026
0bf1eb1
Fix pcntl timeout in AddUserToDiscourseThreadForEvent
edwh May 12, 2026
9776215
Fix null dereference on theGroup before null check in Discourse listener
edwh May 12, 2026
0c45453
Fix navbar Blade @php compilation — use parenthesised @php() form
edwh May 12, 2026
900e89b
Fix Discourse SSO TypeError on null biography — disable bio sync
edwh May 12, 2026
c56a13e
Fix Discourse SSO TypeError: biography accessor returns '' not null
edwh May 12, 2026
6585e30
Update live-patch-status with SSO, putenv and vendor patches
edwh May 12, 2026
77e719f
Update live-patch-status.md: bootstrap/app.php env fix + pcntl guard
edwh May 12, 2026
952e69a
Add queue watchdog cron to detect and recover from hung jobs
edwh May 12, 2026
6e6558a
Add .worktrees to .gitignore
edwh May 13, 2026
a311621
Replace frontend Google Maps key with server-side proxy
edwh May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .fly/scripts/startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ if [ -n "$AWS_BUCKET" ]; then
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
fi

# Ensure queue watchdog is in crontab (image crontab may predate this entry)
if ! crontab -l 2>/dev/null | grep -q 'queue-watchdog'; then
( crontab -l 2>/dev/null; echo "* * * * * /usr/local/bin/queue-watchdog.sh" ) | crontab -
fi

# Run DB setup in a subshell so failures never prevent supervisord from starting
(
# Wait for MySQL to be reachable
Expand All @@ -83,7 +88,8 @@ fi
echo "WARNING: Database not reachable after 60s, skipping migrations"
fi

# Cache config/routes/views for performance (non-fatal)
# Clear stale compiled assets from previous deploy, then rebuild
php /var/www/artisan view:clear 2>/dev/null || true
php /var/www/artisan config:cache 2>/dev/null || true
php /var/www/artisan route:cache 2>/dev/null || true
php /var/www/artisan view:cache 2>/dev/null || true
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/node_modules
/.worktrees
/public/storage
/public/uploads
/public/devices.csv
Expand Down
27 changes: 19 additions & 8 deletions Dockerfile.fly
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,23 @@
cron \
gettext-base \
redis-server \
procps \
libpng-dev libjpeg62-turbo-dev libfreetype6-dev libzip-dev libicu-dev libxml2-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo_mysql bcmath zip intl gd exif \
&& docker-php-ext-install pdo_mysql bcmath zip intl gd exif pcntl \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

# Install xmlrpc
RUN pecl install channel://pecl.php.net/xmlrpc-1.0.0RC3 && docker-php-ext-enable xmlrpc

# Configure PHP-FPM to use unix socket
RUN sed -i 's|listen = 127.0.0.1:9000|listen = /var/run/php-fpm.sock|' /usr/local/etc/php-fpm.d/www.conf && \
# Configure PHP-FPM to use unix socket.
# The base image ships docker.conf (formerly zz-docker.conf) with:
# listen = 9000 — overrides www.conf (last-file-wins in glob)
# clear_env = no — lets Fly.io env vars reach PHP workers (must keep)
# We patch docker.conf: keep clear_env=no, switch listen to unix socket.
# www.conf has no bare `listen =` line, so we append one there as belt-and-braces.
RUN sed -i 's|^listen = .*|listen = /var/run/php-fpm.sock|' /usr/local/etc/php-fpm.d/docker.conf && \
rm -f /usr/local/etc/php-fpm.d/zz-docker.conf && \
sed -i 's|;listen.owner = www-data|listen.owner = www-data|' /usr/local/etc/php-fpm.d/www.conf && \
sed -i 's|;listen.group = www-data|listen.group = www-data|' /usr/local/etc/php-fpm.d/www.conf && \
sed -i 's|;listen.mode = 0660|listen.mode = 0660|' /usr/local/etc/php-fpm.d/www.conf && \
Expand All @@ -92,8 +99,7 @@
sed -i 's|pm.min_spare_servers = [0-9]*|pm.min_spare_servers = 10|' /usr/local/etc/php-fpm.d/www.conf && \
sed -i 's|pm.max_spare_servers = [0-9]*|pm.max_spare_servers = 50|' /usr/local/etc/php-fpm.d/www.conf && \
sed -i 's|;pm.status_path = .*|pm.status_path = /fpm-status|' /usr/local/etc/php-fpm.d/www.conf && \
sed -i 's|;pm.status_listen = .*|pm.status_listen = 127.0.0.1:9001|' /usr/local/etc/php-fpm.d/www.conf && \
rm -f /usr/local/etc/php-fpm.d/zz-docker.conf
sed -i 's|;pm.status_listen = .*|pm.status_listen = 127.0.0.1:9001|' /usr/local/etc/php-fpm.d/www.conf

# Install sysstat (sar), vim, procps (vmstat) and iputils-ping for diagnostics
RUN apt-get update && apt-get install -y --no-install-recommends sysstat vim-tiny procps iputils-ping && \
Expand All @@ -104,7 +110,8 @@
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/conf.d/php.ini && \
echo "upload_max_filesize = 100M" >> /usr/local/etc/php/conf.d/php.ini && \
echo "post_max_size = 100M" >> /usr/local/etc/php/conf.d/php.ini && \
echo "memory_limit = 1024M" >> /usr/local/etc/php/conf.d/php.ini
echo "memory_limit = 1024M" >> /usr/local/etc/php/conf.d/php.ini && \
echo "max_execution_time = 120" >> /usr/local/etc/php/conf.d/php.ini

# Copy nginx config
COPY docker/nginx-fly.conf /etc/nginx/nginx.conf
Expand All @@ -115,8 +122,12 @@
# Copy logrotate config
COPY docker/logrotate-fly.conf /etc/logrotate.d/restarters

# Set up cron for Laravel scheduler (every minute)
RUN echo "* * * * * cd /var/www && php artisan schedule:run >> /dev/null 2>&1" | crontab -
# Copy queue watchdog
COPY docker/queue-watchdog.sh /usr/local/bin/queue-watchdog.sh
RUN chmod +x /usr/local/bin/queue-watchdog.sh

Check warning on line 127 in Dockerfile.fly

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this RUN instruction with the consecutive ones.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ4iKl1TnZPgGqVvFggS&open=AZ4iKl1TnZPgGqVvFggS&pullRequest=849

# Set up cron: Laravel scheduler + queue watchdog (kills hung jobs, supervisord restarts worker)
RUN printf '* * * * * cd /var/www && php artisan schedule:run >> /dev/null 2>&1\n* * * * * /usr/local/bin/queue-watchdog.sh\n' | crontab -

Check warning on line 130 in Dockerfile.fly

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Line is too long. Split it into multiple lines using backslash continuations.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ4iKl1TnZPgGqVvFggT&open=AZ4iKl1TnZPgGqVvFggT&pullRequest=849

# Set working directory
WORKDIR /var/www
Expand Down
58 changes: 14 additions & 44 deletions app/Device.php
Original file line number Diff line number Diff line change
Expand Up @@ -418,62 +418,32 @@ public function getImages()

public function fixedPoweredCount()
{
// We want fixed devices with an powered category.
$count = self::where('repair_status', '=', env('DEVICE_FIXED'))->withCount(['deviceCategory' => function ($query) {
$query->where('powered', 1);
}])->get();

$total = 0;
foreach ($count as $c) {
$total += $c->device_category_count;
}

return $total;
return self::join('categories', 'devices.category', '=', 'categories.idcategories')
->where('repair_status', env('DEVICE_FIXED'))
->where('powered', 1)
->count();
}

public function fixedUnpoweredCount()
{
// We want fixed devices with an unpowered category.
$count = self::where('repair_status', '=', env('DEVICE_FIXED'))->withCount(['deviceCategory' => function ($query) {
$query->where('powered', 0);
}])->get();

$total = 0;
foreach ($count as $c) {
$total += $c->device_category_count;
}

return $total;
return self::join('categories', 'devices.category', '=', 'categories.idcategories')
->where('repair_status', env('DEVICE_FIXED'))
->where('powered', 0)
->count();
}

public function unpoweredCount()
{
// We want devices with an unpowered category.
$count = self::withCount(['deviceCategory' => function ($query) {
$query->where('powered', 0);
}])->get();

$total = 0;
foreach ($count as $c) {
$total += $c->device_category_count;
}

return $total;
return self::join('categories', 'devices.category', '=', 'categories.idcategories')
->where('powered', 0)
->count();
}

public function poweredCount()
{
// We want devices with an powered category.
$count = self::withCount(['deviceCategory' => function ($query) {
$query->where('powered', 1);
}])->get();

$total = 0;
foreach ($count as $c) {
$total += $c->device_category_count;
}

return $total;
return self::join('categories', 'devices.category', '=', 'categories.idcategories')
->where('powered', 1)
->count();
}

public static function getItemTypes()
Expand Down
7 changes: 7 additions & 0 deletions app/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Exceptions;

use Illuminate\Validation\ValidationException;
use Throwable;
use Exception;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
Expand All @@ -28,6 +29,12 @@ class Handler extends ExceptionHandler
public function render($request, Throwable $exception)
{
if ($request->wantsJson()) {
if ($exception instanceof ValidationException) {
return response()->json(
['message' => $exception->getMessage(), 'errors' => $exception->errors()],
422);
}

return response()->json(
['message' => $exception->getMessage()],
method_exists($exception, 'getStatusCode') ? $exception->getStatusCode() : 500);
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/API/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ protected static function mapUserAndAuditToUserChange($user, $audit)
public function notifications(Request $request, int $id): JsonResponse
{
$user = User::findOrFail($id);
$restartersNotifications = $user->unReadNotifications->count();
$restartersNotifications = $user->unreadNotifications()->count();
$discourseNotifications = 0;

if (config('restarters.features.discourse_integration')) {
Expand Down
92 changes: 53 additions & 39 deletions app/Http/Controllers/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,48 +51,62 @@ public static function homepage_data(): JsonResponse
{
$result = [];

$lock = \Cache::lock('homepage_data_lock', 60);

if (\Cache::has('homepage_data')) {
$result = \Cache::get('homepage_data');
} else {
$Device = new Device;

$allparties = Party::past()->get();

$participants = 0;
$hours_volunteered = 0;

foreach ($allparties as $party) {
$participants += $party->pax;

$hours_volunteered += $party->hoursVolunteered();
} elseif ($lock->get()) {
try {
$Device = new Device;

// Aggregate participants and hours in SQL — avoids loading 18k+ event rows into PHP.
// hoursVolunteered() formula: cancelled→3, volunteers>0→9+volunteers*ceil(minutes/60), else→21
$eventStats = DB::table('events')
->whereNull('deleted_at')
->where('event_end_utc', '<', now())
->selectRaw("
SUM(pax) as participants,
SUM(CASE
WHEN cancelled = 1 THEN 3
WHEN volunteers > 0 THEN 9 + volunteers * CEIL(TIMESTAMPDIFF(MINUTE, event_start_utc, event_end_utc) / 60)
ELSE 21
END) as hours_volunteered
")
->first();

$result['participants'] = (int) ($eventStats->participants ?? 0);
$result['hours_volunteered'] = (int) ($eventStats->hours_volunteered ?? 0);

$fixed = $Device->statusCount();
$result['items_fixed'] = count($fixed) ? $fixed[0]->counter : 0;

$stats = \App\Helpers\LcaStats::getWasteStats();
$result['waste_powered'] = round($stats[0]->powered_waste);
$result['waste_unpowered'] = round($stats[0]->unpowered_waste);
$result['waste_total'] = round($stats[0]->powered_waste + $stats[0]->unpowered_waste);
$result['co2_powered'] = round($stats[0]->powered_footprint);
$result['co2_unpowered'] = round($stats[0]->unpowered_footprint);
$result['co2_total'] = round($stats[0]->powered_footprint + $stats[0]->unpowered_footprint);

$devices = new Device;
$result['fixed_powered'] = $devices->fixedPoweredCount();
$result['fixed_unpowered'] = $devices->fixedUnpoweredCount();
$result['total_powered'] = $devices->poweredCount();
$result['total_unpowered'] = $devices->unpoweredCount();

// for backward compatibility (don't break therestartproject.org)
$result['weights'] = round($result['waste_total']);
$result['ewaste'] = round($result['waste_powered']);
$result['unpowered_waste'] = round($result['waste_unpowered']);
$result['emissions'] = round($result['co2_total']);

\Cache::put('homepage_data', $result, 43200);
} finally {
$lock->release();
}

$result['participants'] = $participants;
$result['hours_volunteered'] = $hours_volunteered;
$fixed = $Device->statusCount();
$result['items_fixed'] = count($fixed) ? $fixed[0]->counter : 0;

$stats = \App\Helpers\LcaStats::getWasteStats();
$result['waste_powered'] = round($stats[0]->powered_waste);
$result['waste_unpowered'] = round($stats[0]->unpowered_waste);
$result['waste_total'] = round($stats[0]->powered_waste + $stats[0]->unpowered_waste);
$result['co2_powered'] = round($stats[0]->powered_footprint);
$result['co2_unpowered'] = round($stats[0]->unpowered_footprint);
$result['co2_total'] = round($stats[0]->powered_footprint + $stats[0]->unpowered_footprint);

$devices = new Device;
$result['fixed_powered'] = $devices->fixedPoweredCount();
$result['fixed_unpowered'] = $devices->fixedUnpoweredCount();
$result['total_powered'] = $devices->poweredCount();
$result['total_unpowered'] = $devices->unpoweredCount();

// for backward compatibility (don't break therestartproject.org)
$result['weights'] = round($result['waste_total']);
$result['ewaste'] = round($result['waste_powered']);
$result['unpowered_waste'] = round($result['waste_unpowered']);
$result['emissions'] = round($result['co2_total']);

\Cache::put('homepage_data', $result, 43200);
} else {
// Another worker is rebuilding — return stale or empty rather than pile on
$result = \Cache::get('homepage_data', []);
}

return response()
Expand Down
41 changes: 41 additions & 0 deletions app/Http/Controllers/MapsProxyController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class MapsProxyController extends Controller
{
private function apiKey(): string
{
return config('GOOGLE_API_CONSOLE_KEY') ?? env('GOOGLE_API_CONSOLE_KEY', '');
}

public function autocomplete(Request $request): JsonResponse
{
$request->validate(['input' => 'required|string']);

$response = Http::get('https://maps.googleapis.com/maps/api/place/autocomplete/json', [
'input' => $request->input('input'),
'types' => $request->input('types', 'geocode'),
'key' => $this->apiKey(),
]);

return response()->json($response->json());
}

public function placeDetails(Request $request): JsonResponse
{
$request->validate(['place_id' => 'required|string']);

$response = Http::get('https://maps.googleapis.com/maps/api/place/details/json', [
'place_id' => $request->input('place_id'),
'fields' => 'geometry,formatted_address,address_components',
'key' => $this->apiKey(),
]);

return response()->json($response->json());
}
}
13 changes: 12 additions & 1 deletion app/Http/Controllers/NetworkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Auth;
use FixometerFile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Lang;

class NetworkController extends Controller
Expand Down Expand Up @@ -130,13 +131,23 @@ public function update(Request $request, Network $network): RedirectResponse
$this->authorize('update', $network);

if ($request->hasFile('network_logo')) {
// Determine the correct disk to use (s3 on Fly, public_uploads in dev)
$disk = config('filesystems.default') === 's3' ? 's3' : 'public_uploads';

// Save the file.
$path = $request->file('network_logo')->store('network_logos', [
'disk' => 'public_uploads',
'disk' => $disk,
]);

// Store it in the network object.
if ($path) {
// Generate the _x100 sized version by copying the file
$sizedPath = preg_replace('/\.([^.\s]{3,4})$/', '-_x100.$1', $path);
$storage = Storage::disk($disk);

// Copy the uploaded file to the _x100 filename
$storage->copy($path, $sizedPath);

$network->logo = $path;
$network->save();
} else {
Expand Down
1 change: 1 addition & 0 deletions app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class Kernel extends HttpKernel
\App\Http\Middleware\VerifyTranslationAccess::class,
],
'api' => [
\App\Http\Middleware\AddCorsHeaders::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
Expand Down
Loading
Loading