Records every successful document download and surfaces the counts in the admin area, so the docket owner can see which filings are being pulled and how often. - document_accesses table + DocumentAccess model - DocumentController@download records each successful download with IP and user agent - documents:import-access-log backfills history from an archived nginx log - Dashboard gains a 60-day downloads card and a most-requested list - New Document Access page with a date-range filter and per-day trend Client IPs were only captured from 2026-07-30, when the reverse proxy chain started forwarding X-Forwarded-For correctly. Rows imported from the old nginx log therefore have no IP, and the UI labels those periods as download counts rather than unique visitors instead of showing a misleading number.
118 lines
3.9 KiB
PHP
118 lines
3.9 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Document;
|
|
use App\Models\DocumentAccess;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Console\Command;
|
|
|
|
class ImportDocumentAccessLog extends Command
|
|
{
|
|
protected $signature = 'documents:import-access-log
|
|
{file : Path to an nginx access log in NCSA combined format}
|
|
{--source=nginx-backfill : Value stored in the source column}
|
|
{--dry-run : Parse and report without writing anything}';
|
|
|
|
protected $description = 'Backfill document_accesses from an archived nginx access log';
|
|
|
|
/**
|
|
* Matches e.g.
|
|
* 172.18.0.1 - - [23/May/2026:18:50:19 +0000] "GET /api/documents/83/download HTTP/1.1" 200 12345 "-" "Mozilla/5.0 ..."
|
|
*
|
|
* Only status 200 counts as an access: 499 means the client aborted the
|
|
* transfer, and 4xx/5xx never delivered the file.
|
|
*/
|
|
private const LINE = '#^\S+ \S+ \S+ \[([^\]]+)\] "GET /api/documents/(\d+)/download [^"]*" 200 \S+ "[^"]*" "([^"]*)"#';
|
|
|
|
public function handle(): int
|
|
{
|
|
$file = $this->argument('file');
|
|
|
|
if (! is_readable($file)) {
|
|
$this->error("Cannot read file: {$file}");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$source = $this->option('source');
|
|
$dryRun = $this->option('dry-run');
|
|
|
|
// Only import accesses for documents that still exist; the FK would
|
|
// reject the rest and a deleted document has no dashboard row anyway.
|
|
$knownIds = Document::pluck('id')->flip();
|
|
|
|
$handle = fopen($file, 'r');
|
|
$rows = [];
|
|
$parsed = $skippedUnknown = $unmatched = 0;
|
|
|
|
while (($line = fgets($handle)) !== false) {
|
|
if (! preg_match(self::LINE, $line, $m)) {
|
|
// Most lines are ordinary page views, not downloads.
|
|
if (str_contains($line, '/download')) {
|
|
$unmatched++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
[, $timestamp, $documentId, $userAgent] = $m;
|
|
|
|
if (! $knownIds->has((int) $documentId)) {
|
|
$skippedUnknown++;
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'document_id' => (int) $documentId,
|
|
'accessed_at' => Carbon::createFromFormat('d/M/Y:H:i:s O', $timestamp)->utc(),
|
|
'ip_address' => null, // never recorded by the old log format
|
|
'user_agent' => $userAgent,
|
|
'source' => $source,
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
];
|
|
$parsed++;
|
|
}
|
|
|
|
fclose($handle);
|
|
|
|
$this->info("Parsed {$parsed} successful downloads from {$file}");
|
|
|
|
if ($skippedUnknown > 0) {
|
|
$this->warn("Skipped {$skippedUnknown} referring to documents that no longer exist");
|
|
}
|
|
|
|
if ($unmatched > 0) {
|
|
$this->warn("Skipped {$unmatched} download lines that were not status 200 (aborted or failed)");
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$this->comment('Dry run: nothing written.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
// Idempotency: a re-run must not double-count. Every row from a given
|
|
// log carries the same source, so clearing that source first makes the
|
|
// import safely repeatable.
|
|
$existing = DocumentAccess::where('source', $source)->count();
|
|
|
|
if ($existing > 0) {
|
|
if (! $this->confirm("{$existing} rows with source '{$source}' already exist. Replace them?", true)) {
|
|
$this->comment('Aborted; nothing written.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
DocumentAccess::where('source', $source)->delete();
|
|
}
|
|
|
|
foreach (array_chunk($rows, 500) as $chunk) {
|
|
DocumentAccess::insert($chunk);
|
|
}
|
|
|
|
$this->info('Imported '.count($rows)." rows with source '{$source}'.");
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
}
|