mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Count the sessions a public demo hands out, without keeping an address
Admin → Demo visitors, in demo mode only: sessions started in the last hour, 24 hours, 7 days and 30 days, the different visitors behind them, the total and how many have a mailbox now. The users cannot be counted because the reaper deletes them, so /demo writes a row of its own. That row holds a keyed hash of the address with its last part cut off (a /24 or a /48), never the address, and the reaper removes the hash after 30 days. The demo's privacy notice describes it. Outside demo mode the section is not in the nav, its route answers 404 and nothing is recorded; the table exists and stays empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99b2bd1478
commit
ec938bca99
19 files changed
+842
-2
No files matched your search
@@ -8,12 +8,25 @@ The published image tags: `latest` follows the most recent release below,
|
||||
|
||||
## Unreleased
|
||||
|
||||
**A public demo can see how much it is used.** In demo mode the admin area has a new section, Demo
|
||||
visitors: how many demo sessions were started in the last hour, 24 hours, 7 days and 30 days, how
|
||||
many different visitors started them, the total so far and how many have a mailbox right now. No
|
||||
address is stored. What is kept per session is a keyed hash of the visitor's address with its last
|
||||
part cut off, and that hash is deleted after 30 days. The demo's privacy notice says so. None of
|
||||
this exists on a normal install: the section is not in the navigation, its address answers 404 and
|
||||
nothing is recorded. See [Demo mode](docs/install/demo-mode.md#seeing-how-much-it-is-used).
|
||||
|
||||
**A message no longer grows down the page without end.** Some mail sizes its outer block as "one
|
||||
screen tall". Inside plMail a message sits in a frame that is made as tall as its content, so the
|
||||
block grew with the frame, the frame grew to fit the block, and the message kept getting longer,
|
||||
pushing everything below it away. Sizes given in screen heights are now ignored inside a message,
|
||||
and a message whose content merely follows the frame is no longer resized again.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **One migration, applied on boot.** It adds an empty table, `demo_visit`, which only a demo
|
||||
instance ever writes to.
|
||||
|
||||
## v0.3.1 — 2026-10-08
|
||||
|
||||
**Mail with no plain-text part is read for insights.** Parcels, invoices, codes and the rest were
|
||||
|
||||
@@ -134,6 +134,26 @@ docker compose exec php php bin/console app:demo:reap --dry-run
|
||||
A user with no expiry stamp is left alone rather than treated as overdue — the safe direction for
|
||||
the failure that matters, which is a reaper that deletes the administrator.
|
||||
|
||||
### Seeing how much it is used
|
||||
|
||||
**Admin → Demo visitors** counts the sessions the instance has handed out: in the last hour, the
|
||||
last 24 hours, the last 7 days and the last 30 days, each beside the number of different visitors
|
||||
who started them, plus an all-time total and how many visitors have a mailbox right now. The section
|
||||
exists only in demo mode. On a normal install it is not in the navigation and its address answers
|
||||
404.
|
||||
|
||||
The users themselves cannot be counted, because the reaper deletes them, so `/demo` writes one row
|
||||
per session into a table of its own. That row holds no address. The visitor's address is cut down
|
||||
first — the last octet of an IPv4 address, the last 80 bits of an IPv6 one — and what is stored is a
|
||||
hash of the remainder, keyed with the instance's `APP_SECRET`. So the most that could ever be worked
|
||||
back out of the table, by somebody holding the key, is a network and not a person.
|
||||
|
||||
Two consequences follow. People who share a network count as one visitor, so "different visitors"
|
||||
is a slight undercount. And the hash is deleted after 30 days by the same `app:demo:reap` run that
|
||||
removes expired visitors, which is why the all-time total has no visitor count beside it.
|
||||
|
||||
The privacy notice at `/datenschutz` describes this under its own heading.
|
||||
|
||||
### Sizing it
|
||||
|
||||
Each visitor costs one user, one account, ten threads and ten contacts. Provisioning is rate
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Where a public demo counts the sessions it hands out, for Admin → Demo
|
||||
* visitors. See App\Entity\Demo\DemoVisit.
|
||||
*
|
||||
* A new, empty table. It is created on every install because the schema is one
|
||||
* schema, and it is written to only in demo mode, so on a normal install it
|
||||
* stays empty.
|
||||
*/
|
||||
final class Version20261009120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add demo_visit: one row per demo session handed out, with a keyed hash in place of an address';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE demo_visit (
|
||||
id SERIAL NOT NULL,
|
||||
visitor_hash VARCHAR(64) DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY(id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_demo_visit_created_at ON demo_visit (created_at)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE demo_visit');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Command\Demo;
|
||||
|
||||
use App\Repository\Demo\DemoVisitRepository;
|
||||
use App\Repository\User\UserRepository;
|
||||
use App\Service\Demo\DemoMode;
|
||||
use App\Service\Demo\DemoProvisioner;
|
||||
@@ -35,6 +36,11 @@ use Throwable;
|
||||
* That is the safe direction for the failure that matters: an administrator
|
||||
* signed into their own demo instance has no stamp, and a reaper that read a
|
||||
* missing date as "long overdue" would delete them.
|
||||
*
|
||||
* It also takes the visitor hash off visits older than the statistics count
|
||||
* unique visitors over — see DemoVisitRepository::forgetVisitorsBefore(). Here
|
||||
* because this is the demo's one clean-up job, and a second command on a second
|
||||
* timer for one UPDATE would be a second thing to forget to run.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:demo:reap',
|
||||
@@ -47,6 +53,7 @@ final class ReapDemoUsersCommand extends Command
|
||||
private readonly DemoUserEraser $eraser,
|
||||
private readonly DemoMode $demoMode,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly DemoVisitRepository $visits,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -108,6 +115,12 @@ final class ReapDemoUsersCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
if (false === $dryRun) {
|
||||
$this->visits->forgetVisitorsBefore(
|
||||
$now->modify(sprintf('-%d days', DemoVisitRepository::HASH_RETENTION_DAYS)),
|
||||
);
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'%s %d expired demo user(s).',
|
||||
true === $dryRun ? 'Would delete' : 'Deleted',
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Repository\Monitoring\ClientErrorRepository;
|
||||
use App\Repository\Monitoring\LogEntryRepository;
|
||||
use App\Repository\Monitoring\LogSettingsRepository;
|
||||
use App\Repository\Monitoring\PostgresStatusRepository;
|
||||
use App\Service\Demo\DemoMode;
|
||||
use App\Service\Monitoring\AdminMonitoringService;
|
||||
use App\Service\Monitoring\DbPerformanceService;
|
||||
use App\Service\Monitoring\LogLevelResolver;
|
||||
@@ -32,7 +33,8 @@ final class AdminDashboardController extends AbstractController
|
||||
{
|
||||
use ChecksCsrf;
|
||||
|
||||
private const array SECTIONS = ['system', 'performance', 'database', 'logs', 'insight-reports', 'address', 'integrations', 'push', 'ai', 'users', 'backup', 'updates', 'reset'];
|
||||
private const array SECTIONS = ['system', 'performance', 'database', 'logs', 'insight-reports', 'address', 'integrations', 'push', 'ai', 'users', 'backup', 'updates', 'reset', self::SECTION_DEMO];
|
||||
private const string SECTION_DEMO = 'demo';
|
||||
private const int LOGS_PER_PAGE = 100;
|
||||
|
||||
/**
|
||||
@@ -65,6 +67,7 @@ final class AdminDashboardController extends AbstractController
|
||||
private readonly InsightReportRepository $insightReports,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly UpdateChecker $updates,
|
||||
private readonly DemoMode $demoMode,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'dashboard')]
|
||||
@@ -76,6 +79,12 @@ final class AdminDashboardController extends AbstractController
|
||||
$section = 'system';
|
||||
}
|
||||
|
||||
// The demo's own section exists on a demo and nowhere else. Asked for
|
||||
// on a normal install it is an unknown section like any other.
|
||||
if (self::SECTION_DEMO === $section && false === $this->demoMode->isEnabled()) {
|
||||
$section = 'system';
|
||||
}
|
||||
|
||||
return $this->render('admin/index.html.twig', [
|
||||
'section' => $section,
|
||||
// The nav badge, counted here rather than inside the reported-mail
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Repository\Demo\DemoVisitRepository;
|
||||
use App\Repository\User\UserRepository;
|
||||
use App\Service\Demo\DemoMode;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Admin → Demo visitors: how many sessions a public demo has handed out, and to
|
||||
* roughly how many different people.
|
||||
*
|
||||
* Demo mode only, and a 404 without it rather than an empty table, for the
|
||||
* reason DemoController gives: on a normal install nothing about the demo
|
||||
* should look as though it had been routed.
|
||||
*
|
||||
* Read-only, and rendered into its own Turbo Frame like the other sections.
|
||||
*/
|
||||
#[Route('/admin/demo-stats', name: 'app_admin_demo_stats_')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
final class DemoStatsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* The windows, in the order the table shows them. A month is thirty days:
|
||||
* it is also how long a visit keeps its visitor hash, so the longest window
|
||||
* is exactly as far back as unique visitors can be counted.
|
||||
*/
|
||||
private const array WINDOWS = [
|
||||
'hour' => '-1 hour',
|
||||
'day' => '-1 day',
|
||||
'week' => '-7 days',
|
||||
'month' => '-'.DemoVisitRepository::HASH_RETENTION_DAYS.' days',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly DemoMode $demoMode,
|
||||
private readonly DemoVisitRepository $visits,
|
||||
private readonly UserRepository $users,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'panel', methods: ['GET'])]
|
||||
public function panel(): Response
|
||||
{
|
||||
if (false === $this->demoMode->isEnabled()) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
$windows = [];
|
||||
|
||||
foreach (self::WINDOWS as $name => $since) {
|
||||
$windows[$name] = $this->visits->countSince($now->modify($since));
|
||||
}
|
||||
|
||||
return $this->render('admin/demo/_frame.html.twig', [
|
||||
'windows' => $windows,
|
||||
'total' => $this->visits->countAll(),
|
||||
// Visitors whose two hours are not up yet: the users the reaper
|
||||
// has not taken, which is everybody who could be looking right now.
|
||||
'active' => $this->users->countDemoVisitors(),
|
||||
'retentionDays' => DemoVisitRepository::HASH_RETENTION_DAYS,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,16 @@ namespace App\Controller\Demo;
|
||||
|
||||
use App\Controller\ChecksCsrf;
|
||||
use App\Controller\RendersTurboStreams;
|
||||
use App\Entity\Demo\DemoVisit;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Demo\DemoVisitRepository;
|
||||
use App\Repository\Mail\AccountRepository;
|
||||
use App\Security\LoginFormAuthenticator;
|
||||
use App\Service\Demo\DemoInbox;
|
||||
use App\Service\Demo\DemoMode;
|
||||
use App\Service\Demo\DemoProvisioner;
|
||||
use App\Service\Demo\DemoScenarios;
|
||||
use App\Service\Demo\DemoVisitorFingerprint;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
@@ -45,6 +48,7 @@ final class DemoController extends AbstractController
|
||||
private readonly DemoProvisioner $provisioner,
|
||||
private readonly DemoInbox $inbox,
|
||||
private readonly DemoScenarios $scenarios,
|
||||
private readonly DemoVisitorFingerprint $fingerprint,
|
||||
private readonly AccountRepository $accounts,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly RateLimiterFactoryInterface $demoProvisionLimiter,
|
||||
@@ -92,6 +96,12 @@ final class DemoController extends AbstractController
|
||||
|
||||
$user = $this->provisioner->provision();
|
||||
|
||||
// Counted for Admin → Demo visitors, which cannot count the users: the
|
||||
// reaper takes them. The address goes no further than this line — what
|
||||
// is stored is DemoVisitorFingerprint's hash of the network it is in.
|
||||
$this->entityManager->persist(new DemoVisit($this->fingerprint->of($request->getClientIp())));
|
||||
$this->entityManager->flush();
|
||||
|
||||
$security->login($user, LoginFormAuthenticator::class);
|
||||
|
||||
return $this->redirectToRoute('app_default_index');
|
||||
@@ -210,6 +220,8 @@ final class DemoController extends AbstractController
|
||||
'operatorEmail' => $this->impressumEmail,
|
||||
'host' => $this->privacyHost,
|
||||
'ttl' => $this->demoMode->ttlDescription(),
|
||||
// Read off the constant the reaper acts on, like the TTL above.
|
||||
'statsRetentionDays' => DemoVisitRepository::HASH_RETENTION_DAYS,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Demo;
|
||||
|
||||
use App\Domain\Trait\TimestampableTrait;
|
||||
use App\Repository\Demo\DemoVisitRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* One demo session handed out, for Admin → Demo visitors.
|
||||
*
|
||||
* A row of its own rather than a count over the users, because the users do not
|
||||
* last: app:demo:reap deletes a visitor two hours after they arrive, and with
|
||||
* them every trace that they came. "How many this week" has to be written down
|
||||
* somewhere the reaper does not go.
|
||||
*
|
||||
* It holds no address. `visitorHash` is what DemoVisitorFingerprint makes of
|
||||
* one — see there for what it can and cannot be turned back into — and it is
|
||||
* here only so that the same visitor coming back counts once. It is emptied
|
||||
* after DemoVisitRepository::HASH_RETENTION_DAYS, which leaves the row counting
|
||||
* a visit and nothing more.
|
||||
*
|
||||
* Written only by DemoController::start(), which does not exist outside demo
|
||||
* mode, so on a normal install this table is created and stays empty.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DemoVisitRepository::class)]
|
||||
#[ORM\Table(name: 'demo_visit')]
|
||||
#[ORM\Index(name: 'idx_demo_visit_created_at', columns: ['created_at'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class DemoVisit
|
||||
{
|
||||
use TimestampableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
public private(set) ?int $id = null;
|
||||
|
||||
/**
|
||||
* A keyed hash of the visitor's network, or null: when the request carried
|
||||
* no address, and on every row old enough to have had it taken away.
|
||||
*/
|
||||
#[ORM\Column(length: 64, nullable: true)]
|
||||
public ?string $visitorHash = null;
|
||||
|
||||
public function __construct(?string $visitorHash = null)
|
||||
{
|
||||
$this->visitorHash = $visitorHash;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Demo;
|
||||
|
||||
use App\Entity\Demo\DemoVisit;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<DemoVisit>
|
||||
*/
|
||||
class DemoVisitRepository extends ServiceEntityRepository
|
||||
{
|
||||
/**
|
||||
* How long a visit keeps its visitor hash. The longest window the panel
|
||||
* counts unique visitors over, and not a day more: past it the hash answers
|
||||
* no question anybody asks, so it is not kept.
|
||||
*/
|
||||
public const int HASH_RETENTION_DAYS = 30;
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DemoVisit::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions started since $since, and how many different visitors started
|
||||
* them.
|
||||
*
|
||||
* A visit without a hash is in the first number and not the second:
|
||||
* COUNT(DISTINCT) skips nulls, which is the honest reading of "we could not
|
||||
* tell who this was".
|
||||
*
|
||||
* @return array{sessions: int, visitors: int}
|
||||
*/
|
||||
public function countSince(DateTimeImmutable $since): array
|
||||
{
|
||||
/** @var array{sessions: int|string, visitors: int|string} $row */
|
||||
$row = $this->createQueryBuilder('visit')
|
||||
->select('COUNT(visit.id) AS sessions', 'COUNT(DISTINCT visit.visitorHash) AS visitors')
|
||||
->where('visit.createdAt >= :since')
|
||||
->setParameter('since', $since)
|
||||
->getQuery()
|
||||
->getSingleResult();
|
||||
|
||||
return ['sessions' => (int) $row['sessions'], 'visitors' => (int) $row['visitors']];
|
||||
}
|
||||
|
||||
/** Every session this instance has ever handed out. */
|
||||
public function countAll(): int
|
||||
{
|
||||
return $this->count([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the visitor hash off every visit older than the retention period,
|
||||
* and answers how many rows that was.
|
||||
*
|
||||
* The rows stay, because the all-time total is a count of them. One UPDATE,
|
||||
* run by app:demo:reap every ten minutes, so it finds a handful of rows or
|
||||
* none.
|
||||
*/
|
||||
public function forgetVisitorsBefore(DateTimeImmutable $cutoff): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('visit')
|
||||
->update()
|
||||
->set('visit.visitorHash', ':none')
|
||||
->where('visit.createdAt < :cutoff')
|
||||
->andWhere('visit.visitorHash IS NOT NULL')
|
||||
->setParameter('none', null)
|
||||
->setParameter('cutoff', $cutoff)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,21 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo visitors who have an account right now: the users app:demo:reap has
|
||||
* not taken yet. The mirror of countExcludingDemoVisitors(), on the same
|
||||
* predicate.
|
||||
*/
|
||||
public function countDemoVisitors(): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('usr')
|
||||
->select('COUNT(usr.id)')
|
||||
->where('usr.email LIKE :demoAddress')
|
||||
->setParameter('demoAddress', DemoMode::USER_PREFIX.'%@'.DemoMode::USER_DOMAIN)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist $user as the first administrator, or return false if someone
|
||||
* else got there first.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Demo;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
* Turns a visitor's address into something that can be counted and cannot be
|
||||
* turned back into the address.
|
||||
*
|
||||
* Two steps, and the order is the point.
|
||||
*
|
||||
* The address is cut down first: the last octet of an IPv4 address and the last
|
||||
* eighty bits of an IPv6 one are zeroed, which leaves the network a visitor came
|
||||
* from and not the visitor. Eighty rather than the sixty-four IpUtils::anonymize
|
||||
* removes by default: a /64 is commonly one household's own prefix, and a /48
|
||||
* is the provider's allocation it was cut from. Only then is it hashed, with
|
||||
* the instance's secret as the key.
|
||||
*
|
||||
* Hashing alone would not do. There are four billion IPv4 addresses, and
|
||||
* anybody holding the key can hash all of them in an afternoon and read the
|
||||
* table back; a keyed hash of a whole address is the address under another
|
||||
* name. Cutting first means the most that can ever be recovered — by the
|
||||
* operator, with the key, trying — is a /24, which is what the address was
|
||||
* reduced to before anything was stored.
|
||||
*
|
||||
* The cost is that two people behind the same network count as one visitor.
|
||||
* For "roughly how many different people looked at the demo" that is a small
|
||||
* undercount, and it is the right direction to be wrong in.
|
||||
*/
|
||||
final readonly class DemoVisitorFingerprint
|
||||
{
|
||||
/** Bytes zeroed at the end of an address before it is hashed: a /24 and a /48 remain. */
|
||||
private const int IPV4_BYTES_REMOVED = 1;
|
||||
private const int IPV6_BYTES_REMOVED = 10;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.secret%')]
|
||||
private string $secret,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Null for a request without an address, and for anything that is not one:
|
||||
* a visit nobody can be told apart by is counted as a visit and left out of
|
||||
* the unique visitors.
|
||||
*/
|
||||
public function of(?string $clientIp): ?string
|
||||
{
|
||||
if (null === $clientIp || false === filter_var($clientIp, FILTER_VALIDATE_IP)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hash_hmac('sha256', IpUtils::anonymize($clientIp, self::IPV4_BYTES_REMOVED, self::IPV6_BYTES_REMOVED), $this->secret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% import '_partials/_card_chrome.html.twig' as chrome %}
|
||||
{#
|
||||
admin/demo/_frame.html.twig — Admin → Demo visitors. Demo mode only.
|
||||
|
||||
Accepts:
|
||||
windows — { hour|day|week|month: { sessions, visitors } }
|
||||
total — every session this instance has handed out
|
||||
active — demo users the reaper has not taken yet
|
||||
retentionDays — how long a visit keeps its visitor hash
|
||||
|
||||
No auto-refresh: these are counts for looking at now and then, and the next
|
||||
visit to the page reads them again.
|
||||
#}
|
||||
<turbo-frame id="admin-demo">
|
||||
|
||||
<div class="{{ chrome.shell() }}">
|
||||
|
||||
<div class="flex items-center gap-2.5 {{ chrome.band() }}">
|
||||
<i class="fa-solid fa-user-clock text-xs text-ink-faint" aria-hidden="true"></i>
|
||||
<h2 class="text-sm font-semibold text-ink">{{ 'admin.demo_stats.heading'|trans }}</h2>
|
||||
<span class="ml-auto text-[11px] font-medium px-2 py-0.5 rounded-full bg-raised text-ink-muted">
|
||||
{{ 'admin.demo_stats.active'|trans({ '%count%': active }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="{{ chrome.body() }} space-y-5">
|
||||
|
||||
<p class="text-sm text-ink-soft leading-relaxed">{{ 'admin.demo_stats.intro'|trans }}</p>
|
||||
|
||||
<table class="w-full max-w-md text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-ink-faint">
|
||||
<th scope="col" class="py-1.5 font-medium">{{ 'admin.demo_stats.col.period'|trans }}</th>
|
||||
<th scope="col" class="py-1.5 font-medium text-right">{{ 'admin.demo_stats.col.sessions'|trans }}</th>
|
||||
<th scope="col" class="py-1.5 font-medium text-right">{{ 'admin.demo_stats.col.visitors'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-line">
|
||||
{% for name, counts in windows %}
|
||||
<tr data-window="{{ name }}">
|
||||
<th scope="row" class="py-1.5 text-left font-normal text-ink-soft">{{ ('admin.demo_stats.window.' ~ name)|trans }}</th>
|
||||
<td class="py-1.5 text-right font-mono text-ink" data-count="sessions">{{ counts.sessions }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-ink" data-count="visitors">{{ counts.visitors }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr data-window="total">
|
||||
<th scope="row" class="py-1.5 text-left font-normal text-ink-soft">{{ 'admin.demo_stats.window.total'|trans }}</th>
|
||||
<td class="py-1.5 text-right font-mono text-ink" data-count="sessions">{{ total }}</td>
|
||||
<td class="py-1.5 text-right text-ink-faint">–</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="text-[11px] text-ink-faint leading-relaxed">
|
||||
{{ 'admin.demo_stats.privacy'|trans({ '%days%': retentionDays }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</turbo-frame>
|
||||
@@ -77,7 +77,7 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if section not in ['performance', 'logs', 'insight-reports', 'address', 'integrations', 'push', 'ai', 'backup', 'updates', 'reset'] %}
|
||||
{% if section not in ['performance', 'logs', 'insight-reports', 'address', 'integrations', 'push', 'ai', 'backup', 'updates', 'reset', 'demo'] %}
|
||||
<span class="ml-auto text-xs text-ink-faint">{{ 'admin.refresh_hint'|trans }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -195,6 +195,16 @@
|
||||
aria-label="{{ 'admin.updates.state.available'|trans }}"></span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{# Only on a public demo: how many sessions it has
|
||||
handed out. A normal install has no such
|
||||
section, in the nav or behind it. #}
|
||||
{% if demo.enabled %}
|
||||
<a href="{{ path('app_admin_dashboard', { section: 'demo' }) }}"
|
||||
class="{{ section == 'demo' ? navActive : navInactive }}">
|
||||
<i class="fa-solid fa-user-clock w-4 {{ section == 'demo' ? 'text-ink-faint' : '' }}" aria-hidden="true"></i>
|
||||
{{ 'admin.nav.demo_stats'|trans }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{# Last, and the only one tinted: everything above reads or
|
||||
repairs, this one deletes. #}
|
||||
<a href="{{ path('app_admin_dashboard', { section: 'reset' }) }}"
|
||||
@@ -391,6 +401,19 @@
|
||||
</div>
|
||||
</turbo-frame>
|
||||
|
||||
{% elseif section == 'demo' %}
|
||||
|
||||
{# The controller only lets this section
|
||||
through in demo mode. #}
|
||||
<turbo-frame id="admin-demo"
|
||||
class="block space-y-3"
|
||||
src="{{ path('app_admin_demo_stats_panel') }}"
|
||||
loading="lazy">
|
||||
<div class="py-10 text-center text-sm text-ink-faint">
|
||||
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
|
||||
</div>
|
||||
</turbo-frame>
|
||||
|
||||
{% elseif section == 'reset' %}
|
||||
|
||||
{# Emphatically no auto-refresh: one of the forms in
|
||||
|
||||
@@ -73,6 +73,17 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-ink-faint">
|
||||
{{ 'demo.privacy.stats_title'|trans }}
|
||||
</h3>
|
||||
{# What DemoController::start() writes down, and for how long
|
||||
DemoVisitRepository keeps the part that tells visitors apart. #}
|
||||
<p class="mt-1 text-sm text-ink-muted leading-relaxed">
|
||||
{{ 'demo.privacy.stats'|trans({ '%days%': statsRetentionDays }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-ink-faint">
|
||||
{{ 'demo.privacy.cookies_title'|trans }}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\Demo\DemoVisit;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Demo\DemoVisitRepository;
|
||||
use App\Repository\User\UserRepository;
|
||||
use App\Service\Demo\DemoMode;
|
||||
use App\Service\Demo\DemoUserEraser;
|
||||
use App\Service\Demo\DemoVisitorFingerprint;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
|
||||
/**
|
||||
* Admin → Demo visitors: counted on a demo, absent everywhere else, and never
|
||||
* holding an address.
|
||||
*
|
||||
* Demo mode is flipped the way DemoFlowTest flips it — through $_SERVER before
|
||||
* the kernel boots — which is what lets the "switched off" half be tested.
|
||||
*/
|
||||
final class DemoStatsPanelTest extends WebTestCase
|
||||
{
|
||||
/** TEST-NET-3: an address nobody has, so nothing here can be anyone's. */
|
||||
private const string VISITOR_IP = '203.0.113.57';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
unset($_SERVER['APP_DEMO_MODE'], $_ENV['APP_DEMO_MODE']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$container = static::getContainer();
|
||||
$entities = $container->get(EntityManagerInterface::class);
|
||||
$mode = $container->get(DemoMode::class);
|
||||
|
||||
foreach ($container->get(UserRepository::class)->findAll() as $user) {
|
||||
if (true === $mode->ownsAddress($user->email)) {
|
||||
$container->get(DemoUserEraser::class)->erase($user);
|
||||
}
|
||||
}
|
||||
|
||||
$entities->createQuery('DELETE FROM '.DemoVisit::class)->execute();
|
||||
|
||||
unset($_SERVER['APP_DEMO_MODE'], $_ENV['APP_DEMO_MODE']);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testThePanelDoesNotExistOnANormalInstall(): void
|
||||
{
|
||||
$client = $this->client(demo: false);
|
||||
$this->signIn($client, 'e2e-admin@plmail.test');
|
||||
|
||||
$client->request('GET', '/admin/demo-stats');
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testANormalInstallHasNoSuchSection(): void
|
||||
{
|
||||
$client = $this->client(demo: false);
|
||||
$this->signIn($client, 'e2e-admin@plmail.test');
|
||||
|
||||
$crawler = $client->request('GET', '/admin?section=demo');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(0, $crawler->filter('turbo-frame#admin-demo'), 'the section falls back to the default');
|
||||
self::assertCount(0, $crawler->filter('a[href$="section=demo"]'), 'and the nav does not offer it');
|
||||
}
|
||||
|
||||
public function testOnADemoTheSectionIsInTheNavAndLoadsThePanel(): void
|
||||
{
|
||||
$client = $this->client(demo: true);
|
||||
$this->signIn($client, 'e2e-admin@plmail.test');
|
||||
|
||||
$crawler = $client->request('GET', '/admin?section=demo');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertCount(1, $crawler->filter('turbo-frame#admin-demo[src="/admin/demo-stats"]'));
|
||||
self::assertGreaterThan(0, $crawler->filter('a[href$="section=demo"]')->count());
|
||||
}
|
||||
|
||||
public function testOnlyAnAdministratorReachesIt(): void
|
||||
{
|
||||
$client = $this->client(demo: true);
|
||||
$this->signIn($client, 'e2e@plmail.test');
|
||||
|
||||
$client->request('GET', '/admin/demo-stats');
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole path: a visitor follows /demo, a row is written that holds a
|
||||
* hash and no address, and the panel counts it.
|
||||
*/
|
||||
public function testStartingADemoIsCountedWithoutTheAddress(): void
|
||||
{
|
||||
$client = $this->client(demo: true);
|
||||
|
||||
$client->request('GET', '/demo', server: ['REMOTE_ADDR' => self::VISITOR_IP]);
|
||||
|
||||
self::assertResponseRedirects();
|
||||
|
||||
$container = static::getContainer();
|
||||
$visits = $container->get(DemoVisitRepository::class)->findAll();
|
||||
|
||||
self::assertCount(1, $visits);
|
||||
self::assertSame($container->get(DemoVisitorFingerprint::class)->of(self::VISITOR_IP), $visits[0]->visitorHash);
|
||||
self::assertNotNull($visits[0]->visitorHash);
|
||||
self::assertStringNotContainsString('203.0.113', (string) $visits[0]->visitorHash);
|
||||
|
||||
$this->signIn($client, 'e2e-admin@plmail.test');
|
||||
$crawler = $client->request('GET', '/admin/demo-stats');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('1', trim($crawler->filter('tr[data-window="hour"] [data-count="sessions"]')->text()));
|
||||
self::assertSame('1', trim($crawler->filter('tr[data-window="hour"] [data-count="visitors"]')->text()));
|
||||
self::assertSame('1', trim($crawler->filter('tr[data-window="total"] [data-count="sessions"]')->text()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Two sessions from one network are two sessions and one visitor, and a
|
||||
* visit older than a window is outside it.
|
||||
*/
|
||||
public function testWindowsCountSessionsAndDifferentVisitorsSeparately(): void
|
||||
{
|
||||
$client = $this->client(demo: true);
|
||||
$this->signIn($client, 'e2e-admin@plmail.test');
|
||||
|
||||
$container = static::getContainer();
|
||||
$entities = $container->get(EntityManagerInterface::class);
|
||||
$fingerprint = $container->get(DemoVisitorFingerprint::class);
|
||||
|
||||
$entities->persist(new DemoVisit($fingerprint->of('203.0.113.1')));
|
||||
$entities->persist(new DemoVisit($fingerprint->of('203.0.113.2')));
|
||||
$entities->persist(new DemoVisit($fingerprint->of('198.51.100.9')));
|
||||
$entities->persist($this->visitAt('-3 days', $fingerprint->of('192.0.2.4')));
|
||||
$entities->flush();
|
||||
|
||||
$crawler = $client->request('GET', '/admin/demo-stats');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('3', trim($crawler->filter('tr[data-window="day"] [data-count="sessions"]')->text()));
|
||||
self::assertSame('2', trim($crawler->filter('tr[data-window="day"] [data-count="visitors"]')->text()));
|
||||
self::assertSame('4', trim($crawler->filter('tr[data-window="week"] [data-count="sessions"]')->text()));
|
||||
self::assertSame('3', trim($crawler->filter('tr[data-window="week"] [data-count="visitors"]')->text()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Past the retention period a visit is still a visit, and no longer
|
||||
* anybody's.
|
||||
*/
|
||||
public function testAnOldVisitLosesItsHashAndKeepsItsPlaceInTheTotal(): void
|
||||
{
|
||||
$this->client(demo: true);
|
||||
|
||||
$container = static::getContainer();
|
||||
$entities = $container->get(EntityManagerInterface::class);
|
||||
$repository = $container->get(DemoVisitRepository::class);
|
||||
|
||||
$entities->persist($this->visitAt('-40 days', 'old'));
|
||||
$entities->persist($this->visitAt('-2 days', 'recent'));
|
||||
$entities->flush();
|
||||
|
||||
$cutoff = new DateTimeImmutable(sprintf('-%d days', DemoVisitRepository::HASH_RETENTION_DAYS));
|
||||
|
||||
self::assertSame(1, $repository->forgetVisitorsBefore($cutoff));
|
||||
|
||||
$entities->clear();
|
||||
|
||||
$hashes = array_map(static fn (DemoVisit $visit): ?string => $visit->visitorHash, $repository->findBy([], ['createdAt' => 'ASC']));
|
||||
|
||||
self::assertSame([null, 'recent'], $hashes);
|
||||
self::assertSame(2, $repository->countAll());
|
||||
}
|
||||
|
||||
private function visitAt(string $when, ?string $hash): DemoVisit
|
||||
{
|
||||
$visit = new DemoVisit($hash);
|
||||
$visit->restoreCreatedAt(new DateTimeImmutable($when));
|
||||
|
||||
return $visit;
|
||||
}
|
||||
|
||||
private function client(bool $demo): KernelBrowser
|
||||
{
|
||||
if (true === $demo) {
|
||||
$_SERVER['APP_DEMO_MODE'] = '1';
|
||||
$_ENV['APP_DEMO_MODE'] = '1';
|
||||
}
|
||||
|
||||
$client = static::createClient();
|
||||
|
||||
// Per address, and it outlives the test — see DemoFlowTest::demoClient().
|
||||
static::getContainer()
|
||||
->get(RateLimiterFactoryInterface::class.' $demoProvisionLimiter')
|
||||
->create(self::VISITOR_IP)
|
||||
->reset();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function signIn(KernelBrowser $client, string $email): void
|
||||
{
|
||||
$user = static::getContainer()->get(UserRepository::class)->findOneBy(['email' => $email]);
|
||||
|
||||
if (false === $user instanceof User) {
|
||||
self::markTestSkipped('run `app:test:seed-user` first');
|
||||
}
|
||||
|
||||
$client->loginUser($user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Demo;
|
||||
|
||||
use App\Service\Demo\DemoVisitorFingerprint;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* What is stored in place of a demo visitor's address: enough to count them
|
||||
* once, and not enough to say who they were.
|
||||
*/
|
||||
final class DemoVisitorFingerprintTest extends TestCase
|
||||
{
|
||||
public function testTheSameAddressCountsAsTheSameVisitor(): void
|
||||
{
|
||||
$fingerprint = new DemoVisitorFingerprint('secret');
|
||||
|
||||
self::assertSame($fingerprint->of('203.0.113.57'), $fingerprint->of('203.0.113.57'));
|
||||
self::assertNotSame($fingerprint->of('203.0.113.57'), $fingerprint->of('198.51.100.57'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The property the whole class is for. If two addresses that differ only in
|
||||
* the part that was cut off hashed differently, the full address would have
|
||||
* gone into the hash, and a keyed hash of a full IPv4 address can be read
|
||||
* back by anybody holding the key and an afternoon.
|
||||
*/
|
||||
public function testTheAddressIsCutDownBeforeItIsHashed(): void
|
||||
{
|
||||
$fingerprint = new DemoVisitorFingerprint('secret');
|
||||
|
||||
self::assertSame($fingerprint->of('203.0.113.57'), $fingerprint->of('203.0.113.200'));
|
||||
self::assertSame(
|
||||
$fingerprint->of('2001:db8:1234:5678:9abc:def0:1234:5678'),
|
||||
$fingerprint->of('2001:db8:1234:ffff::1'),
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheHashDoesNotContainTheAddress(): void
|
||||
{
|
||||
$hash = (string) new DemoVisitorFingerprint('secret')->of('203.0.113.57');
|
||||
|
||||
self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $hash);
|
||||
self::assertStringNotContainsString('203.0.113', $hash);
|
||||
}
|
||||
|
||||
/** Another instance's table says nothing about this one's visitors. */
|
||||
public function testTheKeyIsPartOfIt(): void
|
||||
{
|
||||
self::assertNotSame(
|
||||
new DemoVisitorFingerprint('one')->of('203.0.113.57'),
|
||||
new DemoVisitorFingerprint('two')->of('203.0.113.57'),
|
||||
);
|
||||
}
|
||||
|
||||
public function testNoAddressIsNoFingerprint(): void
|
||||
{
|
||||
$fingerprint = new DemoVisitorFingerprint('secret');
|
||||
|
||||
self::assertNull($fingerprint->of(null));
|
||||
self::assertNull($fingerprint->of('unknown'));
|
||||
}
|
||||
}
|
||||
@@ -469,6 +469,21 @@ admin:
|
||||
empty: 'Noch keine Abfragen oberhalb des Schwellwerts.'
|
||||
heading: 'Langsamste Abfragen (nach Durchschnittszeit)'
|
||||
hint: 'Abfragen mit durchschnittlich ≥ 5 ms, aktuelle Datenbank.'
|
||||
demo_stats:
|
||||
active: 'Gerade offen: %count%'
|
||||
col:
|
||||
period: 'Zeitraum'
|
||||
sessions: 'Gestartete Sitzungen'
|
||||
visitors: 'Verschiedene Besucher'
|
||||
heading: 'Demo-Besucher'
|
||||
intro: 'Wie viele Demo-Sitzungen diese Instanz vergeben hat. Eine Sitzung ist ein Postfach, das über /demo angelegt wurde; wer nach Ablauf wiederkommt, startet eine neue.'
|
||||
privacy: 'Es wird keine Adresse gespeichert. Jede Sitzung behält einen Hashwert mit geheimem Schlüssel über das Netz des Besuchers (die Adresse ohne ihren letzten Teil), damit jemand, der wiederkommt, einmal zählt; wer sich ein Netz teilt, zählt als einer. Der Hashwert wird nach %days% Tagen gelöscht, deshalb hat die Gesamtzahl keine Besucherzahl.'
|
||||
window:
|
||||
day: 'Letzte 24 Stunden'
|
||||
hour: 'Letzte Stunde'
|
||||
month: 'Letzte 30 Tage'
|
||||
total: 'Insgesamt'
|
||||
week: 'Letzte 7 Tage'
|
||||
failed:
|
||||
delete: 'Löschen'
|
||||
delete_confirm: 'Diese fehlgeschlagene Nachricht endgültig verwerfen?'
|
||||
@@ -700,6 +715,7 @@ admin:
|
||||
ai: KI
|
||||
backup: Sicherung
|
||||
database: Datenbank
|
||||
demo_stats: 'Demo-Besucher'
|
||||
insight_reports: Gemeldete Mails
|
||||
integrations: Integrationen
|
||||
logs: Protokolle
|
||||
@@ -1905,6 +1921,15 @@ demo:
|
||||
ein Widerspruchsrecht und ein Recht auf Datenübertragbarkeit. Wende dich
|
||||
dafür an die oben genannte Adresse.
|
||||
rights_title: Deine Rechte
|
||||
stats: >-
|
||||
Um zu sehen, wie stark die Demo genutzt wird, wird jede gestartete Demo-Sitzung
|
||||
gezählt. Gespeichert werden der Zeitpunkt und ein mit einem geheimen Schlüssel
|
||||
gebildeter Hashwert der um ihren letzten Teil gekürzten IP-Adresse (bei IPv4 das
|
||||
letzte Oktett, bei IPv6 die letzten 80 Bit) — nie die Adresse selbst. Der Hashwert
|
||||
zeigt nur, dass zwei Sitzungen aus demselben Netz kamen, und wird nach %days% Tagen
|
||||
gelöscht (Art. 6 Abs. 1 lit. f DSGVO — berechtigtes Interesse daran zu wissen,
|
||||
wie stark die Demo genutzt wird).
|
||||
stats_title: 'Nutzungsstatistik'
|
||||
third_parties: >-
|
||||
Es werden keine Daten an Dritte weitergegeben. Diese Seite lädt keine
|
||||
externen Schriftarten, Skripte, Karten oder Analysedienste — ihre Content
|
||||
|
||||
@@ -458,6 +458,21 @@ admin:
|
||||
empty: 'No statements above the threshold yet.'
|
||||
heading: 'Slowest queries (by mean time)'
|
||||
hint: 'Statements averaging ≥ 5ms, current database.'
|
||||
demo_stats:
|
||||
active: 'Open now: %count%'
|
||||
col:
|
||||
period: 'Period'
|
||||
sessions: 'Sessions started'
|
||||
visitors: 'Different visitors'
|
||||
heading: 'Demo visitors'
|
||||
intro: 'How many demo sessions this instance has handed out. A session is one mailbox created by following /demo; somebody coming back after theirs expired starts another.'
|
||||
privacy: 'No address is stored. Each session keeps a keyed hash of the visitor''s network (the address with its last part cut off), so that somebody coming back counts once; people sharing a network count as one. The hash is deleted after %days% days, which is why the total has no visitor count.'
|
||||
window:
|
||||
day: 'Last 24 hours'
|
||||
hour: 'Last hour'
|
||||
month: 'Last 30 days'
|
||||
total: 'All time'
|
||||
week: 'Last 7 days'
|
||||
failed:
|
||||
delete: 'Delete'
|
||||
delete_confirm: 'Permanently drop this failed message?'
|
||||
@@ -689,6 +704,7 @@ admin:
|
||||
ai: AI
|
||||
backup: Backup
|
||||
database: Database
|
||||
demo_stats: 'Demo visitors'
|
||||
insight_reports: Reported mail
|
||||
integrations: Integrations
|
||||
logs: Logs
|
||||
@@ -1900,6 +1916,14 @@ demo:
|
||||
correction, erasure or restriction, as well as the right to object to
|
||||
processing and the right to data portability. Write to the address above.
|
||||
rights_title: Your rights
|
||||
stats: >-
|
||||
To see how much the demo is used, each demo session that is started is counted.
|
||||
What is stored is the time and a keyed hash of the IP address with its last part
|
||||
cut off (the last octet of an IPv4 address, the last 80 bits of an IPv6 one) —
|
||||
never the address itself. The hash shows only that two sessions came from the
|
||||
same network, and it is deleted after %days% days (Art. 6(1)(f) GDPR —
|
||||
legitimate interest in knowing how much the demo is used).
|
||||
stats_title: 'Usage statistics'
|
||||
third_parties: >-
|
||||
No data is passed to third parties. This site loads no external fonts,
|
||||
scripts, maps or analytics — its content security policy allows connections
|
||||
|
||||
@@ -402,6 +402,21 @@ admin:
|
||||
empty: 'No queries above the waterline yet.'
|
||||
heading: 'Slowest queries (by middlin'' time)'
|
||||
hint: 'Queries averagin'' ≥ 5ms, this here database.'
|
||||
demo_stats:
|
||||
active: 'Aboard now: %count%'
|
||||
col:
|
||||
period: 'Watch'
|
||||
sessions: 'Berths handed out'
|
||||
visitors: 'Different hands'
|
||||
heading: 'Trial hands'
|
||||
intro: 'How many trial berths this ship has handed out. A berth be one sea chest made by followin'' /demo; a hand comin'' back after theirs ran out takes another.'
|
||||
privacy: 'No address be kept. Each berth keeps a keyed mark o'' th'' visitor''s home waters (th'' address wi'' its tail cut off), so a hand comin'' back counts once; hands sharin'' th'' same waters count as one. Th'' mark be scrubbed after %days% days, which be why th'' grand tally has no count o'' hands.'
|
||||
window:
|
||||
day: 'Last 24 hours'
|
||||
hour: 'Last hour'
|
||||
month: 'Last 30 days'
|
||||
total: 'Since she launched'
|
||||
week: 'Last 7 days'
|
||||
failed:
|
||||
delete: 'Feed to the fishes'
|
||||
delete_confirm: 'Send this lost bottle to Davy Jones for good?'
|
||||
@@ -627,6 +642,7 @@ admin:
|
||||
ai: Oracle
|
||||
backup: "Ship's papers"
|
||||
database: Database
|
||||
demo_stats: 'Trial hands'
|
||||
insight_reports: 'Reported letters'
|
||||
integrations: 'Friendly ports'
|
||||
logs: Ship's log
|
||||
@@ -1787,6 +1803,14 @@ demo:
|
||||
struck, or held back, as well as th' right to object an' th' right to carry
|
||||
yer records elsewhere. Write to th' address above.
|
||||
rights_title: Yer rights
|
||||
stats: >-
|
||||
To see how much th' trial be used, each trial berth handed out be tallied. What
|
||||
be kept be th' hour an' a keyed mark o' th' address wi' its tail cut off (th'
|
||||
last octet o' an IPv4 address, th' last 80 bits o' an IPv6 one) — never th'
|
||||
address itself. Th' mark shows only that two berths came from th' same waters,
|
||||
an' it be scrubbed after %days% days (Art. 6(1)(f) o' th' articles — rightful
|
||||
interest in knowin' how much th' trial be used).
|
||||
stats_title: 'Th'' tally o'' visitors'
|
||||
third_parties: >-
|
||||
Naught be passed to other ships. This page draws no outside letterin',
|
||||
scripts, charts nor countin'-houses — her articles permit lines to this
|
||||
|
||||
Reference in new issue
Block a user