Harden security fixes and repair traffic/route regressions.

Accept legacy route:list --columns, lock timesheet edits, tighten password throttle and session defaults, and fix site traffic date/unique races without Postgres-only SQL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
skysyaz 2026-07-10 20:52:40 +08:00
parent b4b7041b8e
commit b133de92fa
19 changed files with 292 additions and 22 deletions

View File

@ -31,7 +31,7 @@ DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_SECURE_COOKIE=
SESSION_ENCRYPT=false
SESSION_ENCRYPT=true
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=lax
@ -78,6 +78,10 @@ FLARE_TENANT_TAG=quatriz
# Admin audit trail (spatie/laravel-activitylog)
ACTIVITYLOG_ENABLED=true
# Retention period for audit logs. Consider compliance requirements:
# - Financial/payroll data may require 7 years
# - GDPR typically allows 90 days for security logs
# - Review your organization's data retention policy
ACTIVITYLOG_RETENTION_DAYS=90
# Admin dashboard site traffic counter with daily aggregation

2
.gitignore vendored
View File

@ -7,6 +7,8 @@
.phpunit.result.cache
/.codex
/.cursor/
# Explicit: local agent secrets under skills (also covered by /.cursor/)
/.cursor/skills/SECRETS.md
/.idea
/.nova
/.phpunit.cache

View File

@ -0,0 +1,22 @@
<?php
namespace App\Console\Commands;
use Illuminate\Foundation\Console\RouteListCommand as BaseRouteListCommand;
use Symfony\Component\Console\Input\InputOption;
class RouteListCommand extends BaseRouteListCommand
{
/**
* Accept --columns for tooling that still passes the pre-Laravel 9 flag.
* CLI layout is fixed in modern Laravel, so the value is ignored for display.
*
* @return array
*/
protected function getOptions()
{
return array_merge(parent::getOptions(), [
['columns', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Columns to include (accepted for compatibility)'],
]);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Enums;
enum TimesheetStatus: string
{
case DRAFT = 'draft';
case PENDING_PM = 'pending_pm';
case PENDING_PROGRAM_MANAGER = 'pending_program_manager';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function label(): string
{
return match ($this) {
self::DRAFT => 'Draft',
self::PENDING_PM => 'Pending PM',
self::PENDING_PROGRAM_MANAGER => 'Pending Program Manager',
self::APPROVED => 'Approved',
self::REJECTED => 'Rejected',
};
}
public function isEditable(): bool
{
return match ($this) {
self::DRAFT, self::REJECTED => true,
default => false,
};
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
}

View File

@ -425,7 +425,10 @@ class TimesheetResource extends Resource
public static function submitConfirmationMessage(?User $user, Timesheet $record): string
{
$record->loadMissing('project');
// Ensure project is loaded before checking manager status
if (! $record->relationLoaded('project')) {
$record->load('project');
}
if ($user && $record->project?->isManagedBy($user) && $user->canApproveAsPm()) {
return 'Submit this timesheet for program manager approval? You will not be able to edit it until it is rejected or reverted to draft.';
@ -469,7 +472,11 @@ class TimesheetResource extends Resource
static::validateForSubmission($record);
$record->loadMissing('project');
// Ensure project is loaded before accessing relationships
if (! $record->relationLoaded('project')) {
$record->load('project');
}
$project = $record->project;
$requireProgramManager = Setting::programManagerApprovalRequired();

View File

@ -4,6 +4,7 @@ namespace App\Filament\Resources\TimesheetResource\Pages;
use App\Filament\Resources\TimesheetResource;
use App\Filament\Resources\TimesheetResource\Concerns\CanSubmitTimesheet;
use App\Models\Timesheet;
use App\Support\TimesheetAccess;
use Carbon\Carbon;
use Filament\Actions;
@ -28,7 +29,11 @@ class EditTimesheet extends EditRecord
protected function beforeSave(): void
{
$this->record->refresh();
// Filament wraps save in a DB transaction, so this lock holds until commit.
$this->record = Timesheet::query()
->whereKey($this->record->getKey())
->lockForUpdate()
->firstOrFail();
if (! TimesheetAccess::userCanEditTimesheet(auth()->user(), $this->record)) {
throw new AuthorizationException('This timesheet cannot be edited in its current status.');

View File

@ -25,7 +25,7 @@ class SecurityHeaders
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
$response->headers->set('X-Permitted-Cross-Domain-Policies', 'none');
// ponytail: emit HSTS whenever the origin is HTTPS, not just when the
// Emit HSTS whenever the origin is HTTPS, not just when the
// current leg is TLS — behind a TLS-terminating proxy the internal leg
// is plain HTTP and $request->secure() is false, which would silently
// drop HSTS. Trusting APP_URL keeps it on for HTTPS deployments.

View File

@ -12,7 +12,7 @@ trait LogsAuditableChanges
public function getActivitylogOptions(): LogOptions
{
// ponytail: log_name = the acting user's role (or 'system' for console/jobs),
// log_name = the acting user's role (or 'system' for console/jobs),
// so the Audit Log page's "Log" badge reflects who made the change, not a
// hardcoded "admin". This runs at log time, so Auth::user() is available.
return LogOptions::defaults()

View File

@ -111,7 +111,7 @@ class Project extends Model
public function totalHours(): float
{
// ponytail: select only the JSON hour columns (not tasks/notes/etc.)
// Select only the JSON hour columns (not tasks/notes/etc.)
// to bound memory. A SQL JSON sum would be the next step if a project
// ever crosses ~10k timesheet rows, but it diverges per DB dialect.
return (float) $this->timesheets()
@ -132,6 +132,8 @@ class Project extends Model
{
$this->loadMissing('members');
// Keep PHP-side summing: a SQL JSON sum diverges per DB dialect
// (Postgres vs SQLite used in tests).
return $this->timesheets()
->select(['id', 'user_id', 'hours', 'overtime_hours'])
->with('user:id,name')

View File

@ -2,7 +2,9 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
class SiteTrafficDaily extends Model
{
@ -20,9 +22,22 @@ class SiteTrafficDaily extends Model
protected function casts(): array
{
return [
'date' => 'date',
'page_views' => 'integer',
'unique_sessions' => 'integer',
];
}
/**
* Persist as Y-m-d only. SQLite's default date cast stores "Y-m-d H:i:s",
* which breaks unique lookups and assertDatabaseHas.
*/
protected function date(): Attribute
{
return Attribute::make(
get: fn (mixed $value): ?Carbon => $value !== null
? Carbon::parse($value)->startOfDay()
: null,
set: fn (mixed $value): string => Carbon::parse($value)->toDateString(),
);
}
}

View File

@ -99,12 +99,24 @@ class Timesheet extends Model
public function totalRegularHours(): float
{
return array_sum($this->hours ?? [0, 0, 0, 0, 0, 0, 0]);
$hours = $this->hours;
if (! is_array($hours)) {
return 0.0;
}
return array_sum($hours);
}
public function totalOvertimeHours(): float
{
return array_sum($this->overtime_hours ?? [0, 0, 0, 0, 0, 0, 0]);
$overtimeHours = $this->overtime_hours;
if (! is_array($overtimeHours)) {
return 0.0;
}
return array_sum($overtimeHours);
}
public function totalHours(): float
@ -195,7 +207,11 @@ class Timesheet extends Model
return false;
}
$this->loadMissing('project');
// Ensure project is loaded before checking manager status
if (! $this->relationLoaded('project')) {
$this->load('project');
}
$project = $this->project;
if ($this->isPendingPm()) {

View File

@ -3,7 +3,9 @@
namespace App\Providers;
use App\Auth\Http\Responses\LoginResponse as AppLoginResponse;
use App\Console\Commands\RouteListCommand;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Illuminate\Foundation\Console\RouteListCommand as FrameworkRouteListCommand;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -21,6 +23,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
$this->app->singleton(
FrameworkRouteListCommand::class,
fn ($app) => new RouteListCommand($app['router']),
);
}
}

View File

@ -20,7 +20,7 @@ class AuditLogger
return;
}
// ponytail: log_name doubles as the actor's role so the Audit Log page's
// log_name doubles as the actor's role so the Audit Log page's
// "Log" badge reflects who acted (employee/project_admin/admin/...),
// not a hardcoded "admin". Callers may still pass an explicit logName.
$logName ??= Auth::user()?->role ?? 'system';

View File

@ -4,6 +4,7 @@ namespace App\Support;
use App\Models\SiteTrafficDaily;
use Filament\Facades\Filament;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@ -28,10 +29,7 @@ class SiteTrafficRecorder
$isNewSession = $sessionKey === null
|| Cache::add($sessionKey, true, now()->endOfDay());
$row = SiteTrafficDaily::query()->firstOrCreate(
['date' => $date],
['page_views' => 0, 'unique_sessions' => 0],
);
$row = $this->dayRow($date);
$row->increment('page_views');
@ -65,6 +63,27 @@ class SiteTrafficRecorder
->value('page_views');
}
protected function dayRow(string $date): SiteTrafficDaily
{
$existing = SiteTrafficDaily::query()->whereDate('date', $date)->first();
if ($existing) {
return $existing;
}
try {
return SiteTrafficDaily::query()->create([
'date' => $date,
'page_views' => 0,
'unique_sessions' => 0,
]);
} catch (UniqueConstraintViolationException) {
// Concurrent request won the insert; date cast/storage can differ
// across drivers, so look up with whereDate rather than exact match.
return SiteTrafficDaily::query()->whereDate('date', $date)->firstOrFail();
}
}
protected function shouldRecord(Request $request): bool
{
if (! $request->isMethod('GET')) {

View File

@ -2,6 +2,7 @@
namespace App\Support;
use App\Enums\TimesheetStatus;
use App\Models\Project;
use App\Models\Timesheet;
use App\Models\User;
@ -19,6 +20,16 @@ class TimesheetAccess
'rejected',
];
public static function statusOptions(): array
{
return TimesheetStatus::options();
}
public static function statusValues(): array
{
return TimesheetStatus::values();
}
public static function userCanEditTimesheet(User $user, Timesheet $timesheet): bool
{
if (! $timesheet->isEditable()) {
@ -51,7 +62,11 @@ class TimesheetAccess
return true;
}
$timesheet->loadMissing('project');
// Ensure project is loaded for authorization checks
if (! $timesheet->relationLoaded('project')) {
$timesheet->load('project');
}
$project = $timesheet->project;
if (! $project) {

View File

@ -23,7 +23,10 @@ class TimesheetSummaryBuilder
public static function fromRequest(Request $request): self
{
return self::fromValidated($request->all());
// Validation should happen in controller before calling this method
throw new \BadMethodCallException(
'Use fromValidated() with pre-validated data instead of fromRequest()'
);
}
/**

View File

@ -68,7 +68,7 @@ class WatchtowerReporter
}
};
// ponytail: sync in tests so Http::assertSent works without terminate();
// Sync in tests so Http::assertSent works without terminate();
// defer on HTTP so the error response is not blocked by the 3s POST.
if (app()->environment('testing')) {
$send();

View File

@ -7,10 +7,10 @@ use App\Http\Controllers\UptimeHeartbeatController;
use Illuminate\Support\Facades\Route;
Route::get('/set-password/{token}', [SetPasswordController::class, 'show'])
->middleware('throttle:10,1')
->middleware('throttle:5,60')
->name('password.set');
Route::post('/set-password', [SetPasswordController::class, 'store'])
->middleware('throttle:10,1')
->middleware('throttle:5,60')
->name('password.update');
Route::permanentRedirect('/admin/login', '/login');

110
scripts/backup-db-to-r2.sh Executable file
View File

@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Dump Postgres for this app install and upload the gzip archive to Cloudflare R2.
#
# On the VPS (production):
# CLOUDFLARE_API_TOKEN=... ./scripts/backup-db-to-r2.sh
# Or put CLOUDFLARE_API_TOKEN in /root/.config/timesheet/r2.env (chmod 600).
#
# Production VPS install (outside deploy rsync --delete path):
# /usr/local/sbin/timesheet-backup-db-to-r2.sh
# Credentials: /root/.config/timesheet/r2.env (chmod 600)
# Cron (Malaysia time):
# CRON_TZ=Asia/Kuala_Lumpur
# 0 5 * * * /usr/local/sbin/timesheet-backup-db-to-r2.sh >> /var/log/timesheet-db-backup.log 2>&1
set -euo pipefail
APP_PATH="${APP_PATH:-/var/www/timesheet}"
R2_ACCOUNT_ID="${R2_ACCOUNT_ID:-3cb921ee9ff93956079a49b5121f645e}"
R2_BUCKET="${R2_BUCKET:-timesheet}"
R2_PREFIX="${R2_PREFIX:-backups}"
CRED_FILE="${R2_CREDENTIALS_FILE:-/root/.config/timesheet/r2.env}"
LOG_PREFIX="[timesheet-db-backup]"
if [[ -f "$CRED_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$CRED_FILE"
set +a
fi
TOKEN="${CLOUDFLARE_API_TOKEN:-${CF_API_TOKEN:-}}"
if [[ -z "$TOKEN" ]]; then
echo "$LOG_PREFIX CLOUDFLARE_API_TOKEN is required (env or $CRED_FILE)" >&2
exit 1
fi
if [[ ! -f "$APP_PATH/.env" ]]; then
echo "$LOG_PREFIX Missing $APP_PATH/.env" >&2
exit 1
fi
env_get() {
local key="$1"
grep -E "^${key}=" "$APP_PATH/.env" | head -n1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//"
}
DB_CONNECTION="$(env_get DB_CONNECTION)"
if [[ "$DB_CONNECTION" != "pgsql" ]]; then
echo "$LOG_PREFIX Expected DB_CONNECTION=pgsql, got '${DB_CONNECTION:-empty}'" >&2
exit 1
fi
DB_HOST="$(env_get DB_HOST)"
DB_PORT="$(env_get DB_PORT)"
DB_DATABASE="$(env_get DB_DATABASE)"
DB_USERNAME="$(env_get DB_USERNAME)"
DB_PASSWORD="$(env_get DB_PASSWORD)"
: "${DB_HOST:?Missing DB_HOST}"
: "${DB_PORT:?Missing DB_PORT}"
: "${DB_DATABASE:?Missing DB_DATABASE}"
: "${DB_USERNAME:?Missing DB_USERNAME}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
OBJECT_KEY="${R2_PREFIX}/${DB_DATABASE}-${STAMP}.sql.gz"
TMP_DIR="$(mktemp -d /tmp/timesheet-db-backup.XXXXXX)"
DUMP_FILE="${TMP_DIR}/${DB_DATABASE}-${STAMP}.sql.gz"
RESPONSE_FILE="${TMP_DIR}/r2-response.json"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
echo "$LOG_PREFIX Starting dump of ${DB_DATABASE} at ${STAMP}"
export PGPASSWORD="${DB_PASSWORD:-}"
pg_dump \
-h "$DB_HOST" \
-p "$DB_PORT" \
-U "$DB_USERNAME" \
-d "$DB_DATABASE" \
--no-owner \
--no-acl \
| gzip -c > "$DUMP_FILE"
SIZE_BYTES="$(wc -c < "$DUMP_FILE" | tr -d ' ')"
echo "$LOG_PREFIX Dump ready (${SIZE_BYTES} bytes) → r2://${R2_BUCKET}/${OBJECT_KEY}"
HTTP_CODE="$(
curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
-X PUT \
"https://api.cloudflare.com/client/v4/accounts/${R2_ACCOUNT_ID}/r2/buckets/${R2_BUCKET}/objects/${OBJECT_KEY}" \
-H "Authorization: Bearer ${TOKEN}" \
-H 'Content-Type: application/gzip' \
--data-binary @"${DUMP_FILE}"
)"
if [[ "$HTTP_CODE" != "200" ]]; then
echo "$LOG_PREFIX Upload failed (HTTP ${HTTP_CODE})" >&2
cat "$RESPONSE_FILE" >&2 || true
exit 1
fi
if ! grep -q '"success":true' "$RESPONSE_FILE"; then
echo "$LOG_PREFIX Upload response not successful:" >&2
cat "$RESPONSE_FILE" >&2 || true
exit 1
fi
echo "$LOG_PREFIX Upload complete: ${OBJECT_KEY} (${SIZE_BYTES} bytes)"