mirror of
https://github.com/skysyaz/timesheet.git
synced 2026-07-14 08:40:50 +00:00
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:
parent
b4b7041b8e
commit
b133de92fa
@ -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
2
.gitignore
vendored
@ -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
|
||||
|
||||
22
app/Console/Commands/RouteListCommand.php
Normal file
22
app/Console/Commands/RouteListCommand.php
Normal 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)'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
app/Enums/TimesheetStatus.php
Normal file
45
app/Enums/TimesheetStatus.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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.');
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
110
scripts/backup-db-to-r2.sh
Executable 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)"
|
||||
Loading…
x
Reference in New Issue
Block a user