Self-hosters shouldn't be stuck with a hardcoded 100-project limit; MAX_PROJECTS_PER_OWNER now controls it, defaulting to 0 (unlimited). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit was merged in pull request #2.
This commit is contained in:
@@ -29,6 +29,9 @@ APP_ALLOW_REGISTRATION=true
|
|||||||
# to 0 for local development, so links can be resent immediately.
|
# to 0 for local development, so links can be resent immediately.
|
||||||
MAGIC_LINK_RESEND_SECONDS=60
|
MAGIC_LINK_RESEND_SECONDS=60
|
||||||
|
|
||||||
|
# Maximum number of projects a single user may create. 0 means unlimited.
|
||||||
|
MAX_PROJECTS_PER_OWNER=0
|
||||||
|
|
||||||
# Base URL the app is reached at. Verification magic links point here, e.g.
|
# Base URL the app is reached at. Verification magic links point here, e.g.
|
||||||
# <APP_URL>/verify-email?token=... The Docker image serves the SPA and the API
|
# <APP_URL>/verify-email?token=... The Docker image serves the SPA and the API
|
||||||
# together on http://localhost:8080; a host `npm run dev` serves it on :5173.
|
# together on http://localhost:8080; a host `npm run dev` serves it on :5173.
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@ All routes below require `Authorization: Bearer <jwt>`. A project belongs to one
|
|||||||
| `PATCH` | `/api/projects/{id}` | rename the project (`title`) |
|
| `PATCH` | `/api/projects/{id}` | rename the project (`title`) |
|
||||||
| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) |
|
| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) |
|
||||||
|
|
||||||
`GET /api/projects` is always ordered alphabetically (case-insensitive) by title; there is no other sort option. A user may own at most **100 projects** — creating one beyond that responds `409`.
|
`GET /api/projects` is always ordered alphabetically (case-insensitive) by title; there is no other sort option. A user may own at most `MAX_PROJECTS_PER_OWNER` projects (default: unlimited) — creating one beyond that responds `409`.
|
||||||
|
|
||||||
Create/update body: `title` (required, 1–255 chars).
|
Create/update body: `title` (required, 1–255 chars).
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ All settings are optional environment variables (read from `.env` or the real en
|
|||||||
| `JWT_TTL` | `86400` | Token lifetime in seconds |
|
| `JWT_TTL` | `86400` | Token lifetime in seconds |
|
||||||
| `APP_ALLOW_REGISTRATION` | `true` | When `false`, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created |
|
| `APP_ALLOW_REGISTRATION` | `true` | When `false`, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created |
|
||||||
| `MAGIC_LINK_RESEND_SECONDS` | `60` | Minimum gap before a magic link can be resent to the same address (sign-in or email-change). Docker Compose overrides this to `0`, so links resend immediately in development |
|
| `MAGIC_LINK_RESEND_SECONDS` | `60` | Minimum gap before a magic link can be resent to the same address (sign-in or email-change). Docker Compose overrides this to `0`, so links resend immediately in development |
|
||||||
|
| `MAX_PROJECTS_PER_OWNER` | `0` | Maximum number of projects a single user may create. `0` means unlimited |
|
||||||
| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) |
|
| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) |
|
||||||
| `WEBAUTHN_RP_ID` | `APP_URL`'s host | Passkey relying party ID (domain). Must be `localhost` or a real domain over HTTPS — a LAN IP won't work |
|
| `WEBAUTHN_RP_ID` | `APP_URL`'s host | Passkey relying party ID (domain). Must be `localhost` or a real domain over HTTPS — a LAN IP won't work |
|
||||||
| `WEBAUTHN_RP_NAME` | `Projects` | Passkey relying party display name, shown in the browser/OS prompt |
|
| `WEBAUTHN_RP_NAME` | `Projects` | Passkey relying party display name, shown in the browser/OS prompt |
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ use Psr\Http\Message\ServerRequestInterface as Request;
|
|||||||
final class ProjectController extends ProjectScopedController
|
final class ProjectController extends ProjectScopedController
|
||||||
{
|
{
|
||||||
private const TITLE_MAX = 255;
|
private const TITLE_MAX = 255;
|
||||||
private const MAX_PROJECTS_PER_OWNER = 100;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ProjectRepository $projects,
|
ProjectRepository $projects,
|
||||||
private readonly CardStatusRepository $statuses,
|
private readonly CardStatusRepository $statuses,
|
||||||
|
/** Maximum number of projects a single owner may create. 0 means unlimited. */
|
||||||
|
private readonly int $maxProjectsPerOwner,
|
||||||
) {
|
) {
|
||||||
parent::__construct($projects);
|
parent::__construct($projects);
|
||||||
}
|
}
|
||||||
@@ -48,9 +49,9 @@ final class ProjectController extends ProjectScopedController
|
|||||||
$title = $validator->requiredString('title', self::TITLE_MAX);
|
$title = $validator->requiredString('title', self::TITLE_MAX);
|
||||||
$validator->assert();
|
$validator->assert();
|
||||||
|
|
||||||
if ($this->projects->countForOwner($ownerId) >= self::MAX_PROJECTS_PER_OWNER) {
|
if ($this->maxProjectsPerOwner > 0 && $this->projects->countForOwner($ownerId) >= $this->maxProjectsPerOwner) {
|
||||||
throw new ApiException(
|
throw new ApiException(
|
||||||
sprintf('You have reached the maximum of %d projects.', self::MAX_PROJECTS_PER_OWNER),
|
sprintf('You have reached the maximum of %d projects.', $this->maxProjectsPerOwner),
|
||||||
409,
|
409,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ final class Config
|
|||||||
public readonly string $webauthnRpName,
|
public readonly string $webauthnRpName,
|
||||||
/** Minimum gap between magic links sent to the same address. */
|
/** Minimum gap between magic links sent to the same address. */
|
||||||
public readonly int $resendIntervalSeconds,
|
public readonly int $resendIntervalSeconds,
|
||||||
|
/** Maximum number of projects a single owner may create. 0 means unlimited. */
|
||||||
|
public readonly int $maxProjectsPerOwner,
|
||||||
public readonly MailConfig $mail,
|
public readonly MailConfig $mail,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -59,6 +61,7 @@ final class Config
|
|||||||
$webauthnRpName = self::env('WEBAUTHN_RP_NAME', 'Projects');
|
$webauthnRpName = self::env('WEBAUTHN_RP_NAME', 'Projects');
|
||||||
|
|
||||||
$resendIntervalSeconds = (int) (self::env('MAGIC_LINK_RESEND_SECONDS') ?? '60');
|
$resendIntervalSeconds = (int) (self::env('MAGIC_LINK_RESEND_SECONDS') ?? '60');
|
||||||
|
$maxProjectsPerOwner = (int) (self::env('MAX_PROJECTS_PER_OWNER') ?? '0');
|
||||||
|
|
||||||
$mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log');
|
$mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log');
|
||||||
if (!self::isAbsolutePath($mailLogPath)) {
|
if (!self::isAbsolutePath($mailLogPath)) {
|
||||||
@@ -87,6 +90,7 @@ final class Config
|
|||||||
$webauthnRpId,
|
$webauthnRpId,
|
||||||
$webauthnRpName,
|
$webauthnRpName,
|
||||||
$resendIntervalSeconds,
|
$resendIntervalSeconds,
|
||||||
|
$maxProjectsPerOwner,
|
||||||
$mail,
|
$mail,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ $webAuthn = new WebAuthn($config->webauthnRpName, $config->webauthnRpId, ['none'
|
|||||||
|
|
||||||
$authController = new AuthController($users, $session, $verifier, $config->allowRegistration);
|
$authController = new AuthController($users, $session, $verifier, $config->allowRegistration);
|
||||||
$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session);
|
$emailController = new EmailVerificationController($users, $verificationTokens, $verifier, $session);
|
||||||
$projectController = new ProjectController($projects, $cardStatuses);
|
$projectController = new ProjectController($projects, $cardStatuses, $config->maxProjectsPerOwner);
|
||||||
$cardController = new CardController($projects, $cards, $cardStatuses);
|
$cardController = new CardController($projects, $cards, $cardStatuses);
|
||||||
$cardStatusController = new CardStatusController($projects, $cardStatuses, $cards);
|
$cardStatusController = new CardStatusController($projects, $cardStatuses, $cards);
|
||||||
$passkeyController = new PasskeyController($webAuthn, $passkeys, $webauthnChallenges, $users, $session);
|
$passkeyController = new PasskeyController($webAuthn, $passkeys, $webauthnChallenges, $users, $session);
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ final class ProjectTest extends ApiTestCase
|
|||||||
self::assertSame(['apple', 'Banana', 'Cherry'], $titles);
|
self::assertSame(['apple', 'Banana', 'Cherry'], $titles);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_an_owner_cannot_exceed_100_projects(): void
|
public function test_an_owner_cannot_exceed_the_configured_project_limit(): void
|
||||||
{
|
{
|
||||||
|
$this->reconfigure(['MAX_PROJECTS_PER_OWNER' => '100']);
|
||||||
$auth = $this->authHeader();
|
$auth = $this->authHeader();
|
||||||
|
|
||||||
for ($i = 1; $i <= 100; $i++) {
|
for ($i = 1; $i <= 100; $i++) {
|
||||||
|
|||||||
Reference in New Issue
Block a user