Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,13 @@ IPAPI_KEY=
# ---------------------------------------------------------------------------
# Certificates. PDFLATEX_PATH must point at a real pdflatex binary or the
# whole certificate pipeline fails. See docs/handover/08-certificates.md
#
# CERTIFICATE_ADMIN_EMAILS is a comma-separated allowlist for
# /admin/certificate-backend/*. No role grants access, so if this is blank
# the certificate backend is closed to everyone.
# ---------------------------------------------------------------------------
PDFLATEX_PATH=
CERTIFICATE_ADMIN_EMAILS=

# ---------------------------------------------------------------------------
# Storage. RESOURCES_BUCKET is a second bucket for Learn & Teach PDFs, and is
Expand Down
14 changes: 10 additions & 4 deletions app/Console/Commands/SoftDeleteUsersWithoutConsent.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

class SoftDeleteUsersWithoutConsent extends Command
{
Expand All @@ -17,20 +18,19 @@ public function handle()
$this->info('Creating or verifying legacy user...');

// Create legacy user if it doesn't exist
User::firstOrCreate(
$legacyUser = User::firstOrCreate(
['id' => 1000000],
[
'firstname' => 'Codeweek Legacy User',
'lastname' => 'Codeweek Legacy',
'username' => 'codeweek-legacy',
'password' => Hash::make('some-secure-password-' . time()),
'password' => Hash::make(Str::random(40)),
'email' => 'legacy@codeweek.eu',
'country_iso' => 'BE',
'remember_token' => '5Pb4APP0CkOmQFzDtR960OlkdreLev2tvfUiywDawyJOmKDvFb3bmOAWlUL6',
'remember_token' => Str::random(60),
'privacy' => 1,
'email_display' => 'legacy@codeweek.eu',
'receive_emails' => 0,
'approved' => 1,
'magic_key' => 2520090911,
'consent_given_at' => now(),
'future_consent_given_at' => now(),
Expand All @@ -40,6 +40,12 @@ public function handle()
]
);

// 'approved' is guarded against mass assignment, so set it explicitly.
if (! $legacyUser->approved) {
$legacyUser->approved = 1;
$legacyUser->save();
}

$this->info('Starting to process users without consent...');

// Get users without consent in chunks to avoid memory issues
Expand Down
6 changes: 1 addition & 5 deletions app/Http/Controllers/EventController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use App\Queries\EventsQuery;
use App\Queries\PendingEventsQuery;
use App\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -231,10 +230,7 @@ public function reject(Request $request, Event $event)
{
$rejectionText = $request->get('rejectionText', null);

try {
$this->authorize('approve', $event);
} catch (AuthorizationException $e) {
}
$this->authorize('approve', $event);

$event->reject($rejectionText);
}
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public function update()
'lastname' => 'required|string',
'privacy' => 'required',
'receive_emails' => 'required',
'country_iso' => 'nullable|exists:countries,iso',
'country_iso' => 'required|exists:countries,iso',
'city_id' => 'nullable|exists:cities,id',
'twitter' => 'nullable',
'website' => 'nullable',
Expand Down
17 changes: 13 additions & 4 deletions app/Http/Middleware/EnsureSuperCertificateAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,27 @@

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class EnsureSuperCertificateAdmin
{
private const ALLOWED_EMAIL = 'bernard@matrixinternet.ie';

/**
* Handle an incoming request. Only the super certificate admin email can access.
* Handle an incoming request. Only a configured certificate admin can access.
*/
public function handle(Request $request, Closure $next): Response
{
if (! $request->user() || $request->user()->email !== self::ALLOWED_EMAIL) {
$allowed = config('codeweek.certificate_admin_emails', []);

if (empty($allowed)) {
Log::warning('Certificate backend access denied: CERTIFICATE_ADMIN_EMAILS is not set.');

abort(403, 'The certificate administrator list is not configured. Set CERTIFICATE_ADMIN_EMAILS.');
}

$email = $request->user()?->email;

if ($email === null || ! in_array(strtolower($email), array_map('strtolower', $allowed), true)) {
abort(403, 'Access denied. This area is restricted to the certificate administrator.');
}

Expand Down
30 changes: 30 additions & 0 deletions app/Listeners/AlertOnBusyQueue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Listeners;

use Illuminate\Queue\Events\QueueBusy;
use Illuminate\Support\Facades\Log;

class AlertOnBusyQueue
{
/**
* Report a queue that has more jobs waiting than we consider healthy.
*
* Registered by event auto-discovery via the typed parameter below. Laravel
* only fires this event while `queue:monitor` is running, so it is useless
* without the scheduled entry in routes/console.php.
*/
public function handle(QueueBusy $event): void
{
$message = sprintf(
'Queue [%s] on connection [%s] has %d pending jobs.',
$event->queue,
$event->connection,
$event->size
);

Log::error($message);

\Sentry\captureMessage($message);
}
}
22 changes: 22 additions & 0 deletions app/Livewire/LeadingTeachersTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ public function filters(): array
}
}),

SelectFilter::make('City')
->setFilterPillTitle('City')
->setFilterPillValues([
'missing' => 'Not set',
'set' => 'Set',
])
->options([
'' => 'All',
'missing' => 'Not set',
'set' => 'Set',
])
->filter(function(Builder $builder, string $value) {
if ($value === 'missing') {
$builder->whereNull('city_id');
} elseif ($value === 'set') {
$builder->whereNotNull('city_id');
}
}),

];
}

Expand Down Expand Up @@ -101,6 +120,9 @@ public function columns(): array
->searchable(),
Column::make('Country','country_iso')
->sortable(),
Column::make('City', 'city.city')
->sortable()
->searchable(),
Column::make('email')
->sortable()
->searchable(),
Expand Down
9 changes: 7 additions & 2 deletions app/Nova/Actions/RejectEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Support\Collection;
use Laravel\Nova\Actions\Action;
use Laravel\Nova\Fields\ActionFields;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Http\Requests\NovaRequest;

class RejectEvent extends Action
Expand All @@ -22,7 +23,7 @@ class RejectEvent extends Action
public function handle(ActionFields $fields, Collection $models)
{
foreach ($models as $model) {
$model->reject();
$model->reject($fields->rejectionText);
}
}

Expand All @@ -31,6 +32,10 @@ public function handle(ActionFields $fields, Collection $models)
*/
public function fields(NovaRequest $request): array
{
return [];
return [
Textarea::make('Rejection reason', 'rejectionText')
->rules('required', 'string', 'max:2000')
->help('Sent to the organiser in the rejection email and stored on the activity.'),
];
}
}
4 changes: 3 additions & 1 deletion app/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ class User extends Authenticatable implements MustVerifyEmail
*/


protected $guarded = [];
// 'approved' controls whether a leading teacher is listed publicly, so it must
// never be settable from request data. Set it explicitly instead of mass-assigning.
protected $guarded = ['approved'];

/**
* The attributes that should be hidden for arrays.
Expand Down
12 changes: 12 additions & 0 deletions config/codeweek.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,16 @@
'CONTACT_FORM_RECIPIENT_EMAIL',
env('ADMIN_EMAIL', 'admin@codeweek.test')
),

// Pending-job count above which queue:monitor fires QueueBusy. Raise it if
// October traffic makes the alert noisy rather than switching the alert off.
'queue_busy_threshold' => (int) env('QUEUE_BUSY_THRESHOLD', 100),

// Who may use /admin/certificate-backend/*. Comma-separated list of email
// addresses; no role grants access. This used to be a single hardcoded
// address in the middleware, which locked out everyone else.
'certificate_admin_emails' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('CERTIFICATE_ADMIN_EMAILS', ''))
))),
];
33 changes: 22 additions & 11 deletions database/factories/CityFactory.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */
$factory->define(App\City::class, function () {
return [
'id' => $this->faker->numberBetween(1234567890, 9999999999),
'city' => $this->faker->city(),
'country' => $this->faker->country(),
'country_iso' => $this->faker->countryCode(),
'longitude' => $this->faker->longitude(),
'latitude' => $this->faker->latitude(),
];
});
namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

class CityFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'city' => $this->faker->city(),
'country' => $this->faker->country(),
'country_iso' => $this->faker->countryCode(),
'longitude' => $this->faker->longitude(),
'latitude' => $this->faker->latitude(),
];
}
}
4 changes: 2 additions & 2 deletions database/seeders/ResourceEditorRoleSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ public function run(): void
app()['cache']->forget('spatie.permission.cache');

// create permissions
Permission::create(['name' => 'moderate resource']);
Permission::firstOrCreate(['name' => 'moderate resource']);

// create roles and assign created permissions

$role = Role::create(['name' => 'resource editor']);
$role = Role::firstOrCreate(['name' => 'resource editor']);
$role->givePermissionTo(['moderate resource']);

}
Expand Down
12 changes: 8 additions & 4 deletions docs/handover/00-access-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,17 @@ Ask for a `super admin` account on both environments.

### Day-one blocker you must resolve

The certificate administration area at `/admin/certificate-backend/*` is gated on a **single hardcoded email address** belonging to the outgoing team:
The certificate administration area at `/admin/certificate-backend/*` is not governed by roles at all. It reads an explicit allowlist of email addresses from the environment, and it **fails closed**:

```11:11:app/Http/Middleware/EnsureSuperCertificateAdmin.php
private const ALLOWED_EMAIL = 'bernard@matrixinternet.ie';
```19:23:app/Http/Middleware/EnsureSuperCertificateAdmin.php
if (empty($allowed)) {
Log::warning('Certificate backend access denied: CERTIFICATE_ADMIN_EMAILS is not set.');

abort(403, 'The certificate administrator list is not configured. Set CERTIFICATE_ADMIN_EMAILS.');
}
```

No role will get you in. This constant has to be changed (ideally replaced with a role or permission check) before you can operate the certificate tooling at all. The same address is also the fallback recipient for the contact form in `app/Http/Controllers/ContactFormController.php`. Both are listed in [12](12-risks-and-known-issues.md).
So until somebody sets `CERTIFICATE_ADMIN_EMAILS` in Forge, **nobody can operate certificates** — not even a super admin. Add your own address to it on both environments as part of getting set up, and confirm you can load `/admin/certificate-backend`. This used to be a single hardcoded address belonging to the outgoing team; see [12](12-risks-and-known-issues.md).

## 9. A note on server addresses in git history

Expand Down
2 changes: 1 addition & 1 deletion docs/handover/02-environments-and-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ The `composer-test.json` swap is the part to remember. It exists to strip the No

CI triggers on pushes and pull requests for both `master` and `dev`. It previously only ran for `master`, so pull requests into `dev` ran no tests at all.

Two tests fail on `master` for reasons unrelated to any recent change: `UserRestoreServiceTest` and `CommunityAmbassadorFilteringTest`, the latter because `database/factories/CityFactory.php` is still in the pre-Laravel-8 `$factory->define()` format. Everything else passes. Fix or quarantine these, because a suite that is normally red is a suite nobody reads.
The suite is green. It was not when this handover started — two tests failed and five files were silently skipped — so if you see red, it is something you changed rather than inherited noise. Keep it that way: a suite that is normally red is a suite nobody reads. [11](11-testing-and-local-dev.md)

An abandoned Travis configuration targeting PHP 7.3 has been removed, along with `.env.travis`. GitHub Actions is the only pipeline.

Expand Down
20 changes: 20 additions & 0 deletions docs/handover/03-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,26 @@ return [

Note that `blog_url` defaults to the live blog. On a dev or local environment the blog sync command will therefore pull from **production** unless `BLOG_URL` is set explicitly. See [09](09-wordpress-blog.md).

Three more entries live further down the same file and are easy to miss:

| Key | Variable | Notes |
|-----|----------|-------|
| `contact_form_recipient` | `CONTACT_FORM_RECIPIENT_EMAIL` | Falls back to `ADMIN_EMAIL` |
| `queue_busy_threshold` | `QUEUE_BUSY_THRESHOLD` | Pending jobs above which the queue alert fires. Default 100. See [10](10-scheduled-jobs-and-runbooks.md) |
| `certificate_admin_emails` | `CERTIFICATE_ADMIN_EMAILS` | Comma-separated allowlist for the certificate backend. **Blank means nobody has access** |

`certificate_admin_emails` is the one to set before anything else:

```32:38:config/codeweek.php
// Who may use /admin/certificate-backend/*. Comma-separated list of email
// addresses; no role grants access. This used to be a single hardcoded
// address in the middleware, which locked out everyone else.
'certificate_admin_emails' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('CERTIFICATE_ADMIN_EMAILS', ''))
))),
```

## Locales

`LOCALES` currently lists 27 codes:
Expand Down
2 changes: 1 addition & 1 deletion docs/handover/04-domain-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ Notes:

## Roles and permissions

Managed by `spatie/laravel-permission`. Seeded across four seeders.
Managed by `spatie/laravel-permission`. Seeded across four seeders. This section covers what the roles *are*; [14](14-accounts-and-moderation.md) covers how somebody gets one and what each role can actually do.

From [database/seeders/RolesAndPermissionsSeeder.php](../../database/seeders/RolesAndPermissionsSeeder.php):

Expand Down
20 changes: 16 additions & 4 deletions docs/handover/05-nova-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,31 @@ Ten actions in [app/Nova/Actions/](../../app/Nova/Actions).
| Action | Attached to | Effect |
|--------|-------------|--------|
| `ApproveEvent` | `Event` | Calls `$model->approve()` — sets status and emails the organiser |
| `RejectEvent` | `Event` | Calls `$model->reject()`. **No message field** — see below |
| `RejectEvent` | `Event` | Calls `$model->reject($reason)` with a required reason — see below |
| `BulkUploadMediaFiles` | `MediaUpload` | Multi-file upload to S3 |
| `ExportHomeSlideLocaleOverrides` | `HomeSlide` | CSV export of slide translations |
| `ImportHomeSlideLocaleOverrides` | `HomeSlide` | CSV import of slide translations |
| `DownloadMatchmakingTemplate` | `MatchmakingProfile` | Downloads the import template |
| `ImportMatchmakingProfiles` | `MatchmakingProfile` | Bulk profile import |
| `ApproveSupportApproval` | `SupportApproval` | Support copilot workflow |
| `RejectSupportApproval` | `SupportApproval` | Support copilot workflow |
### Rejecting through Nova loses the reason
### Rejecting through Nova

`RejectEvent` exposes no fields, so the `Moderation` record it creates has an empty `message`. The organiser gets a rejection with no explanation. The legacy Blade moderation screens at `/pending` and `/review` do capture a reason.
`RejectEvent` used to expose no fields at all, so the `Moderation` record it wrote had an empty `message` and the organiser received a rejection email with no explanation, while the Blade screens at `/pending` and `/review` captured a reason. Ambassadors use both paths, so the two behaved differently for no reason anyone had chosen.

**Guidance for ambassadors: use `/pending` and `/review` to reject, not Nova.** Or fix the action to include a message field. Either way, decide and communicate it, because right now the two paths behave differently and ambassadors use both.
The action now requires a reason:

```33:39:app/Nova/Actions/RejectEvent.php
public function fields(NovaRequest $request): array
{
return [
Textarea::make('Rejection reason', 'rejectionText')
->rules('required', 'string', 'max:2000')
->help('Sent to the organiser in the rejection email and stored on the activity.'),
];
```

One difference remains, and it is not fixable in the action: **editing the Status field directly on the Event resource bypasses `approve()` and `reject()` entirely**, so no email is sent and no moderation record is written. Use the actions, not the dropdown. [14](14-accounts-and-moderation.md)

## Filters

Expand Down
Loading
Loading