Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f5c23b6
Migrate email-preferences tab to Vue + /api/v2/users/me/preferences (…
edwh May 30, 2026
8ccdf61
Migrate profile calendars tab to Vue + /api/v2/users/me/calendars (Gr…
edwh May 30, 2026
23f1e7f
Migrate profile repair-directory tab to Vue + /api/v2/users/{id}/repa…
edwh May 30, 2026
59caddf
Migrate account language form to Vue + /api/v2/users/me/language (Gro…
edwh May 30, 2026
88d1410
Fix CI for Group 2.2: 401 on unauth API, calendar hash config, orphan…
edwh May 31, 2026
df6e8a3
Re-trigger CI: retry flaky event invite-volunteers Playwright test
edwh May 31, 2026
8146dd9
Fix time-of-day flake in createEvent: pick a future-month date
edwh May 31, 2026
cbcadd2
Merge remote-tracking branch 'origin/develop' into RES-PROFILE-EDIT-vue
edwh Jul 3, 2026
b67126d
Migrate profile-edit "profile" tab to Vue + API v2 (Group 2.2 5/5)
edwh Jul 3, 2026
9c9adb6
Remove orphaned general.other_profile translation key
edwh Jul 3, 2026
ded6f16
a11y: label the calendars "events by area" select and URL input
edwh Jul 3, 2026
88663c5
Migrate profile-edit sensitive tabs to Vue + API (photo, password, ad…
edwh Jul 3, 2026
3207b25
Drop webp from photo upload contract to match FixometerFile support
edwh Jul 3, 2026
7cf75e1
Fix testAdminRemoveReaddHost for the admin-settings Vue migration
edwh Jul 3, 2026
aff159f
Migrate account soft-delete form to Vue + API v2
edwh Jul 4, 2026
574a986
Switch profile photo upload to Uppy + a Laravel tus resumable-upload …
edwh Jul 4, 2026
795b3ba
Fix testRemoveNetworkCoordinatorByRole for the admin-settings Vue mig…
edwh Jul 4, 2026
46a42e2
Drop redundant CSRF exemption for the tus route
edwh Jul 4, 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ restarters-anonymised.sql
/.claude/settings.local.json
/public/build/
/storage/api-docs/api-docs.json
/storage/app/tus
/storage/app/tus-cache
/.phpunit.result.cache
/playwright-report/
/events.csv
Expand Down
19 changes: 19 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\Auth\AuthenticationException;
use Illuminate\Validation\ValidationException;
use Throwable;
use Exception;
Expand All @@ -26,7 +27,7 @@
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function render($request, Throwable $exception)

Check warning on line 30 in app/Exceptions/Handler.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 5 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ8oijCcm1e-wh4t_ivu&open=AZ8oijCcm1e-wh4t_ivu&pullRequest=868
{
if ($request->wantsJson()) {
if ($exception instanceof ValidationException) {
Expand All @@ -35,6 +36,24 @@
422);
}

// An unauthenticated request (e.g. a missing/invalid api token on an
// auth:api route) throws AuthenticationException, which has no
// getStatusCode() and so would otherwise fall through to 500. Map it
// to the correct 401 for JSON/API clients.
if ($exception instanceof AuthenticationException) {
return response()->json(
['message' => $exception->getMessage()],
401);
}

// An authorization failure (e.g. $this->authorize() / a Policy denial)
// throws AuthorizationException, which also has no getStatusCode() and
// so would otherwise fall through to 500. Map it to 403 for JSON/API
// clients.
if ($exception instanceof \Illuminate\Auth\Access\AuthorizationException) {
return response()->json(['message' => $exception->getMessage()], 403);
}

return response()->json(
['message' => $exception->getMessage()],
method_exists($exception, 'getStatusCode') ? $exception->getStatusCode() : 500);
Expand Down
223 changes: 132 additions & 91 deletions app/Helpers/FixometerFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,107 +62,148 @@

/** if we have no error, proceed to elaborate and upload **/
if ($error == UPLOAD_ERR_OK) {
$filename = $this->filename($tmp_name);
$this->file = $filename;
$lpath = $_SERVER['DOCUMENT_ROOT'].'/uploads/'.$filename;

// Attempt the move BEFORE touching existing records — if the move fails
// (e.g. uploads directory missing/unwritable) we preserve the old image.
if (!$this->move($tmp_name, $lpath)) {
return false;
}
return $this->processLocalFile($tmp_name, $type, $reference, $referenceType, $clear, $profile, $crop, true);
}

// Move succeeded — safe to remove previous image records.
if ($clear) {
Xref::where('reference', $reference)
->where('reference_type', $referenceType)
->forceDelete();
}
return null;
}

/**
* Ingest a file that is already sitting on local disk (i.e. NOT a PHP/HTTP
* upload for the current request, so move_uploaded_file() would refuse it) -
* e.g. a file assembled by a tus resumable-upload server. Runs the same
* validation/thumbnailing/DB-record pipeline as upload(), but copies the
* source file into place instead of using move_uploaded_file().
*
* The caller is responsible for having already validated $localPath is a
* real, acceptable image before calling this (this method still re-checks
* the MIME type itself via filename(), same as upload() does).
*/
public function uploadLocalFile(string $localPath, $type, $reference = null, $referenceType = null, $profile = false, $crop = true)
{
if (! is_file($localPath)) {
return null;
}

$data = [];
$this->path = $lpath;
$data['path'] = $this->file;
return $this->processLocalFile($localPath, $type, $reference, $referenceType, true, $profile, $crop, false);
}

/**
* Shared tail-end of upload()/uploadLocalFile(): validate the MIME type,
* move/copy the source file into the uploads directory, generate
* thumbnails, and create the images/xref DB records.
*
* @param bool $useUploadedFileMove true for real PHP uploads (move_uploaded_file(),
* or copy() under FixometerFile::$uploadTesting),
* false to always use a plain copy() (tus/local files).
*/
protected function processLocalFile(string $tmp_name, $type, $reference, $referenceType, bool $clear, bool $profile, bool $crop, bool $useUploadedFileMove)

Check failure on line 100 in app/Helpers/FixometerFile.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=TheRestartProject_restarters.net&issues=AZ8sUnjfN3xCn8X3OJ_x&open=AZ8sUnjfN3xCn8X3OJ_x&pullRequest=868
{
$filename = $this->filename($tmp_name);

// Fix orientation
Image::make($lpath)->orientate()->save($lpath);
if (! $filename) {
return false;
}

$this->file = $filename;
$lpath = $_SERVER['DOCUMENT_ROOT'].'/uploads/'.$filename;

if ($type !== 'image') {
$this->syncToCloud($filename);
// Attempt the move BEFORE touching existing records — if the move fails
// (e.g. uploads directory missing/unwritable) we preserve the old image.
$moved = $useUploadedFileMove ? $this->move($tmp_name, $lpath) : @copy($tmp_name, $lpath);

if (! $moved) {
return false;
}

// Move succeeded — safe to remove previous image records.
if ($clear) {
Xref::where('reference', $reference)
->where('reference_type', $referenceType)
->forceDelete();
}

$data = [];
$this->path = $lpath;
$data['path'] = $this->file;

// Fix orientation
Image::make($lpath)->orientate()->save($lpath);

if ($type !== 'image') {
$this->syncToCloud($filename);
}

if ($type === 'image') {
$size = getimagesize($this->path);
$data['width'] = $size[0];
$data['height'] = $size[1];

if ($profile) {
$data['alt_text'] = 'Profile Picture';
}

if ($type === 'image') {
$size = getimagesize($this->path);
$data['width'] = $size[0];
$data['height'] = $size[1];

if ($profile) {
$data['alt_text'] = 'Profile Picture';
}

if ($data['width'] > $data['height']) {
$biggestSide = $data['width'];
$resize_height = true;
} else {
$biggestSide = $data['height'];
$resize_height = false;
}

$thumbSize = 80;
$midSize = 260;

// Let's make images, which we will resize or crop
$thumb = Image::make($lpath);
$mid = Image::make($lpath);

if ($resize_height) { // Resize before crop
$thumb->resize(null, $thumbSize, function ($constraint) {
$constraint->aspectRatio();
});

$mid->resize(null, $midSize, function ($constraint) {
$constraint->aspectRatio();
});
} else {
$thumb->resize($thumbSize, null, function ($constraint) {
$constraint->aspectRatio();
});

$mid->resize($midSize, null, function ($constraint) {
$constraint->aspectRatio();
});
}

if ($crop) {
$thumb->crop($thumbSize, $thumbSize);
$mid->crop($midSize, $midSize);
}

$thumb->save($_SERVER['DOCUMENT_ROOT'].'/uploads/'.'thumbnail_'.$filename, 85);
$mid->save($_SERVER['DOCUMENT_ROOT'].'/uploads/'.'mid_'.$filename, 85);

$this->syncToCloud($filename);
$this->syncToCloud('thumbnail_'.$filename);
$this->syncToCloud('mid_'.$filename);

$this->table = 'images';
$Images = new Images;

$image = $Images->create($data)->id;

if (is_numeric($image) && ! is_null($reference) && ! is_null($referenceType)) {
Xref::create([
'object' => $image,
'object_type' => env('TBL_IMAGES'),
'reference' => $reference,
'reference_type' => $referenceType,
]);
}
if ($data['width'] > $data['height']) {
$biggestSide = $data['width'];
$resize_height = true;
} else {
$biggestSide = $data['height'];
$resize_height = false;
}

return $filename;
$thumbSize = 80;
$midSize = 260;

// Let's make images, which we will resize or crop
$thumb = Image::make($lpath);
$mid = Image::make($lpath);

if ($resize_height) { // Resize before crop
$thumb->resize(null, $thumbSize, function ($constraint) {
$constraint->aspectRatio();
});

$mid->resize(null, $midSize, function ($constraint) {
$constraint->aspectRatio();
});
} else {
$thumb->resize($thumbSize, null, function ($constraint) {
$constraint->aspectRatio();
});

$mid->resize($midSize, null, function ($constraint) {
$constraint->aspectRatio();
});
}

if ($crop) {
$thumb->crop($thumbSize, $thumbSize);
$mid->crop($midSize, $midSize);
}

$thumb->save($_SERVER['DOCUMENT_ROOT'].'/uploads/'.'thumbnail_'.$filename, 85);
$mid->save($_SERVER['DOCUMENT_ROOT'].'/uploads/'.'mid_'.$filename, 85);

$this->syncToCloud($filename);
$this->syncToCloud('thumbnail_'.$filename);
$this->syncToCloud('mid_'.$filename);

$this->table = 'images';
$Images = new Images;

$image = $Images->create($data)->id;

if (is_numeric($image) && ! is_null($reference) && ! is_null($referenceType)) {
Xref::create([
'object' => $image,
'object_type' => env('TBL_IMAGES'),
'reference' => $reference,
'reference_type' => $referenceType,
]);
}
}

return null;
return $filename;
}

/**
Expand Down
86 changes: 86 additions & 0 deletions app/Helpers/Tus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace App\Helpers;

use TusPhp\Cache\Cacheable;
use TusPhp\Cache\FileStore;
use TusPhp\Tus\Server;

class Tus
{
/**
* Directory where in-progress and completed tus uploads are assembled.
*/
public static function uploadDir(): string
{
return storage_path('app/tus');
}

/**
* Directory where the tus-php file cache (upload metadata) is kept.
*/
public static function cacheDir(): string
{
return storage_path('app/tus-cache/');
}

/**
* Build the tus-php file cache directly, WITHOUT constructing a Server/Request.
*
* TusPhp\Tus\Server's constructor eagerly builds a TusPhp\Request, which calls
* Symfony's HttpRequest::createFromGlobals() - that reads PHP's real superglobals
* unconditionally, which blows up under PHPUnit's CLI SAPI (no $_SERVER['REQUEST_METHOD']
* etc.) once error_reporting is turned up to catch undefined-variable notices as
* errors. Callers that only need to read/write the cache (e.g. updateMyPhotov2()
* looking up a completed upload by key) should use this instead of buildServer()
* so they don't pay for - or crash on - a Request they never use.
*
* The cache key prefix MUST match what Server actually uses: AbstractTus::setCache()
* prefixes every key with "tus:" . strtolower(static::class) . ":" (i.e. "tus:server:"
* for TusPhp\Tus\Server) - a bare `new FileStore()` defaults to just "tus:" and would
* silently miss every entry the real Server wrote.
*/
public static function buildCache(): Cacheable
{
$cacheDir = self::cacheDir();

if (! is_dir($cacheDir)) {
mkdir($cacheDir, 0775, true);
}

$cache = new FileStore($cacheDir);
$cache->setPrefix('tus:server:');

return $cache;
}

/**
* Build a tus-php Server configured identically wherever it's used (the
* TusController that serves the protocol, and the API controller that
* later looks up a completed upload by key). Using a shared factory keeps
* the upload dir/cache dir/api path in sync between the two call sites.
*
* Only call this where a real Request is available/expected (i.e. the actual tus
* protocol route) - see buildCache() for cache-only access.
*/
public static function buildServer(): Server
{
$uploadDir = self::uploadDir();

if (! is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}

$server = new Server(self::buildCache());
$server->setUploadDir($uploadDir);
$server->setApiPath('/api/tus');

// This route is intentionally unauthenticated (see TusController), so cap what an
// anonymous client can push to disk here even though updateMyPhotov2() separately
// enforces its own 2MB limit once a completed upload is claimed. A little headroom
// over the app-level limit avoids rejecting legitimate uploads before compression.
$server->setMaxUploadSize(10 * 1024 * 1024);

return $server;
}
}
Loading