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
52 changes: 45 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,23 @@ npm ci && npm run build

Point your web server at `public/`, then open
**`https://your-domain/install`**. The wizard checks the server
requirements, then asks for the site URL, your name, email and password,
and installs. It only exists while Docket has no user account: once your
login is created, `/install` permanently redirects to the login screen.
requirements, then asks for a setup code, the site URL, your name, email
and password, and installs.

The setup code proves you control the server: anyone can find a fresh
deployment's `/install`, so it won't create the account without it. Read
it on the server once the setup page has loaded:

```sh
cat storage/app/install/setup-code
```

The installer only exists while Docket has no account. Once your login
is created it leaves a marker (`storage/app/install/installed`) and
`/install` permanently redirects to the login screen. If the database
can't be read, it answers 503 rather than treating the site as fresh.
Docket has exactly one account: neither installer, nor `db:seed`, will
create a second one.

### Console installer

Expand Down Expand Up @@ -191,6 +205,10 @@ Development server (app + queue + logs + Vite): `composer dev`.
User=www-data
```

Keep `--timeout` below the queue's `retry_after` (360 seconds by
default, `DB_QUEUE_RETRY_AFTER`), or a slow fetch is handed to a second
worker while the first is still running it.

5. One cron entry drives the scheduler (backups, cleanup, monitor):

```
Expand Down Expand Up @@ -218,18 +236,38 @@ BACKUP_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
BACKUP_REGION=auto
```

Test a restore early:
The database in the archive is an SQL dump (`db-dumps/*.sql`, written by
`sqlite3 .dump`), not a database file: it has to be imported into a new
database. Test a restore early, on a copy of the server if you can:

```sh
php artisan backup:run
# download the newest zip from the bucket, then:
unzip Docket/….zip -d /tmp/restore
mv /tmp/restore/db-dumps/database.sqlite database/database.sqlite
ls /tmp/restore/db-dumps/ # one .sql file

# Build a new database from the dump and check it.
sqlite3 /tmp/restore/restored.sqlite < /tmp/restore/db-dumps/*.sql
sqlite3 /tmp/restore/restored.sqlite "PRAGMA integrity_check;" # expect: ok
sqlite3 /tmp/restore/restored.sqlite "SELECT count(*) FROM items;"
sqlite3 /tmp/restore/restored.sqlite "INSERT INTO items_fts(items_fts) VALUES('rebuild');"

# Swap it in with nothing writing: stop the worker and the site first.
php artisan down
sudo systemctl stop docket-worker # whatever runs queue:work
mkdir -p /tmp/before-restore && cp database/database.sqlite* /tmp/before-restore/
mv /tmp/restore/restored.sqlite database/database.sqlite
rm -f database/database.sqlite-wal database/database.sqlite-shm
rsync -a /tmp/restore/…/storage/app/private/ storage/app/private/
php artisan migrate:status
chown -R www-data: database storage/app/private
php artisan migrate --force # if the backup predates a release
php artisan up
sudo systemctl start docket-worker
```

Open a saved article and check its images load.
Then open a saved article, check its images load, and search for a word
you know is in it. The originals stay in `/tmp/before-restore` until
you're satisfied.

## Security notes

Expand Down
82 changes: 55 additions & 27 deletions app/Console/Commands/DocketInstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Console\Commands;

use App\Exceptions\InstallStateUnknownException;
use App\Services\Installer;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
Expand Down Expand Up @@ -41,7 +42,15 @@ public function handle(Installer $installer): int
return SymfonyCommand::FAILURE;
}

if ($installer->isInstalled() && ! $this->option('force')) {
try {
$installed = $installer->isInstalled();
} catch (InstallStateUnknownException $e) {
$this->components->error($e->getMessage());

return SymfonyCommand::FAILURE;
}

if ($installed && ! $this->option('force')) {
$this->components->warn(
'Docket already has a user account. Re-run with --force to run the installer anyway (existing data is never deleted).'
);
Expand Down Expand Up @@ -95,32 +104,51 @@ public function handle(Installer $installer): int
return SymfonyCommand::FAILURE;
}

// Persist the identity to .env so a later `db:seed` recreates the
// same account rather than the config/docket.php fallback owner.
// The password is deliberately NOT written to .env: it is hashed
// straight into the database below.
$installer->writeEnvValues([
'APP_URL' => $url,
'DOCKET_USER_NAME' => $name,
'DOCKET_USER_EMAIL' => $email,
]);
$this->components->task('.env updated (APP_URL, owner name/email)', fn (): bool => true);

$installer->ensureSqliteDatabase();

$this->components->task('running database migrations', fn (): bool => $installer->migrate());

if ($installer->ownerExists($email)) {
$this->components->warn("An account for {$email} already exists — its password was left unchanged.");
} else {
$this->components->task(
"creating owner account for {$email}",
function () use ($installer, $name, $email, $plainPassword): bool {
$installer->createOwner($name, $email, $plainPassword);

return true;
}
);
// Serialised with the web installer, so the two can't race each
// other into creating two accounts.
$lock = $installer->acquireLock();

if ($lock === null) {
$this->components->error('Another installation is running right now. Try again in a moment.');

return SymfonyCommand::FAILURE;
}

try {
$installer->ensureSqliteDatabase();

$this->components->task('running database migrations', fn (): bool => $installer->migrate());

if ($installer->ownerExists($email)) {
$this->components->warn("An account for {$email} already exists — its password was left unchanged.");
} elseif ($installer->hasOwner()) {
$this->components->warn('Docket already has its owner account, under a different email. It has one account, so no new one was created; change the email on the Profile page instead.');

return SymfonyCommand::FAILURE;
} else {
$this->components->task(
"creating owner account for {$email}",
function () use ($installer, $name, $email, $plainPassword): bool {
$installer->createOwner($name, $email, $plainPassword);

return true;
}
);
}

// Written once the account is in place, so a refused install
// leaves .env alone. The password is deliberately NOT written
// to .env: it is hashed straight into the database.
$installer->writeEnvValues([
'APP_URL' => $url,
'DOCKET_USER_NAME' => $name,
'DOCKET_USER_EMAIL' => $email,
]);
$this->components->task('.env updated (APP_URL, owner name/email)', fn (): bool => true);

$installer->markInstalled();
} finally {
$installer->releaseLock($lock);
}

$this->printNextSteps($url, $email);
Expand Down
5 changes: 4 additions & 1 deletion app/Console/Commands/DocketRefetchCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ public function handle(): int
$spacing = max(0, (int) settings('import.spacing_seconds'));
$count = 0;

foreach ($query->orderBy('id')->lazy() as $item) {
// By ID rather than by offset: items leave the failed/pending
// selection as they are fetched (at once, on a sync queue), and
// offset paging over a shrinking result set skips rows.
foreach ($query->withoutBodies()->lazyById(200, 'items.id', 'id') as $item) {
$this->queue($item, $count * $spacing);
$count++;
}
Expand Down
12 changes: 12 additions & 0 deletions app/Exceptions/InstallStateUnknownException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace App\Exceptions;

use RuntimeException;

/**
* The installer could not tell whether Docket is installed (the database
* exists but can't be read). Treated as "installed" for safety: the
* installer refuses to run rather than risk a second owner account.
*/
class InstallStateUnknownException extends RuntimeException {}
15 changes: 15 additions & 0 deletions app/Exceptions/ResponseTooLargeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace App\Exceptions;

/**
* A download abandoned at its size cap. Permanent, and lets callers that
* keep a byte budget (ImageDownloads) charge the cap that was spent.
*/
class ResponseTooLargeException extends FetchFailedException
{
public function __construct(string $message, public readonly int $limit)
{
parent::__construct($message, true);
}
}
63 changes: 35 additions & 28 deletions app/Http/Controllers/BulkItemController.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,37 +37,44 @@ public function __invoke(Request $request): RedirectResponse
'tags.required_if' => 'Enter the tags to add.',
]);

$items = Item::query()->whereIn('id', $data['ids'])->get();
$tags = Tag::parseList($data['tags'] ?? null);
$count = 0;

foreach ($items as $item) {
switch ($data['action']) {
case 'archive':
$item->archive();
break;
case 'unarchive':
$item->unarchive();
break;
case 'favourite':
$item->setFavourite(true);
break;
case 'unfavourite':
$item->setFavourite(false);
break;
case 'tag':
$item->attachTagNames($tags);
break;
case 'retry':
$this->retry($item);
break;
case 'delete':
// One by one, so each item's stored images go too.
$item->delete();
break;
}
}
// In batches, without the article bodies: a thousand ticked items
// must not mean a thousand full articles in memory at once.
Item::query()
->withoutBodies()
->whereIn('items.id', $data['ids'])
->chunkById(100, function ($items) use ($data, $tags, &$count): void {
foreach ($items as $item) {
$count++;

$count = $items->count();
switch ($data['action']) {
case 'archive':
$item->archive();
break;
case 'unarchive':
$item->unarchive();
break;
case 'favourite':
$item->setFavourite(true);
break;
case 'unfavourite':
$item->setFavourite(false);
break;
case 'tag':
$item->attachTagNames($tags);
break;
case 'retry':
$this->retry($item);
break;
case 'delete':
// One by one, so each item's stored images go too.
$item->delete();
break;
}
}
}, 'items.id', 'id');

return back()->with('status', sprintf(
'%s: %d %s.',
Expand Down
14 changes: 9 additions & 5 deletions app/Http/Controllers/ExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,29 @@
namespace App\Http\Controllers;

use App\Services\Exporter;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;

class ExportController extends Controller
{
/**
* Pocket-shaped CSV: title, url, time_added, tags, status.
* Pocket-shaped CSV: title, url, time_added, tags, status. ?for=spreadsheet
* neutralises cells a spreadsheet would run as formulas.
*/
public function csv(Exporter $exporter): StreamedResponse
public function csv(Request $request, Exporter $exporter): StreamedResponse
{
$forSpreadsheets = $request->query('for') === 'spreadsheet';

return response()->streamDownload(
function () use ($exporter): void {
function () use ($exporter, $forSpreadsheets): void {
$handle = fopen('php://output', 'w');

if ($handle !== false) {
$exporter->writeCsv($handle);
$exporter->writeCsv($handle, $forSpreadsheets);
fclose($handle);
}
},
'docket-export-'.now()->format('Y-m-d').'.csv',
'docket-export-'.now()->format('Y-m-d').($forSpreadsheets ? '-spreadsheet' : '').'.csv',
['Content-Type' => 'text/csv; charset=UTF-8']
);
}
Expand Down
Loading
Loading