applies($file, $viewer)) { return null; } return max(0, (int) $file->download_limit - $this->used($file, $viewer)); } public function allows(File $file, ?User $viewer): bool { $remaining = $this->remaining($file, $viewer); return $remaining === null || $remaining > 0; } /** * How much of the allowance has been spent — the whole file's * downloads under Total, this person's own under PerUser. */ public function used(File $file, ?User $viewer): int { if ($this->scopeFor($file, $viewer) === DownloadLimitScope::PerUser && $viewer !== null) { return $file->downloads()->where('actor_id', $viewer->id)->count(); } return $file->downloads()->count(); } /** * What a listing needs to show, decided here rather than in the * listing. * * Themes render this; they never work it out. The props contract in * docs/theming-files-checklist.md is explicit that a theme must not * re-derive a rule, and there are eight of them — the per-user * scope and the uploader exemption would be wrong in at least one * within a release. * * Reads the counts withCounts() attached, so a page of rows costs * the two subqueries that query already added and nothing further. * Enforcement never comes through here: `own_downloads_count` is * only true for the viewer it was loaded for, and a guard that read * a stale one would be a guard that let the wrong person through. * * @return array{limit: int|null, left: int|null, blocked: bool} */ public function summaryFor(File $file, ?User $viewer): array { if (! $this->applies($file, $viewer)) { return ['limit' => null, 'left' => null, 'blocked' => false]; } $perUser = $this->scopeFor($file, $viewer) === DownloadLimitScope::PerUser; $used = (int) ($perUser ? ($file->own_downloads_count ?? $file->downloads()->where('actor_id', $viewer?->id)->count()) : ($file->downloads_count ?? $file->downloads()->count())); $left = max(0, (int) $file->download_limit - $used); return ['limit' => (int) $file->download_limit, 'left' => $left, 'blocked' => $left === 0]; } /** * Whether any file in this installation has a limit at all. * * One indexed query, so a listing can decide whether to count * downloads per row. The overwhelming majority of installs never set * a limit on anything and should not pay two correlated subqueries * per file for a feature they do not use. */ public function isUsedAnywhere(): bool { return $this->usedAnywhere ??= File::query()->whereNotNull('download_limit')->exists(); } /** * Attach the counts a listing needs to show a spent limit. * * Both are added because the scope varies per row: a page can hold a * Total-limited file next to a PerUser-limited one, and picking the * right number in SQL would mean a correlated subquery that branches * on a column. Cheaper to select both and let the presenter choose — * and only when the install uses limits at all. * * @param Builder $query * @return Builder */ public function withCounts(Builder $query, ?User $viewer): Builder { if (! $this->isUsedAnywhere()) { return $query; } return $this->withOwnCount($query->withCount('downloads'), $viewer); } /** * Just the viewer's own count, for a listing that already selects the * shared one for its own reasons — the staff library shows a * downloads column whether or not anything is limited, so it must * keep asking for that count unconditionally. * * @param Builder $query * @return Builder */ public function withOwnCount(Builder $query, ?User $viewer): Builder { if ($viewer === null || ! $this->isUsedAnywhere()) { return $query; } return $query->withCount(['downloads as own_downloads_count' => fn (Builder $downloads) => $downloads ->where('actor_id', $viewer->id)]); } /** * The scope actually in force for this viewer. * * PerUser has no meaning for a visitor who is not signed in — there * is no "user" to count against. Rather than inventing one from an IP * address (v1 counted anonymous downloads per IP over 24 hours, which * gives a whole office behind one NAT a single shared allowance, and * stops working entirely when Setting::DownloadIpLogging is `none`), * an anonymous visitor falls back to the file's total. */ private function scopeFor(File $file, ?User $viewer): DownloadLimitScope { $scope = $file->download_limit_scope ?? DownloadLimitScope::Total; return $viewer === null ? DownloadLimitScope::Total : $scope; } private function applies(File $file, ?User $viewer): bool { if (! $file->hasDownloadLimit()) { return false; } // The uploader's own downloads never count against their own // limit, and are never refused by it. return ! ($viewer !== null && $file->isOwnedBy($viewer)); } }