From 7e09282a889a69a149bf5f3bc3d256e6f4b74275 Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Sat, 7 Nov 2026 19:44:32 +0100 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80=D1=8B=20=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=D0=B7=20Jambotron:=20=D0=BA=D0=B0=D0=B6?= =?UTF-8?q?=D0=B4=D1=8B=D0=B9=20ADMIN=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D1=8F=D0=B5=D1=82=20=D1=8F=D1=89=D0=B8=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit users.is_admin (V8) — ставит только служебное API (PUT /api/service/accounts с "admin"), ящик сам себя администратором не сделает. MailAdmins: список из конфигурации + выданные Jambotron (загружаются при старте, меняются сразу). Co-Authored-By: Claude Opus 5.5 --- .../com/mailserver/config/MailAdmins.java | 22 +++++++++++-- .../mailserver/config/MailAdminsLoader.java | 32 +++++++++++++++++++ .../controller/ServiceController.java | 24 ++++++++++---- .../main/java/com/mailserver/domain/User.java | 6 ++++ .../com/mailserver/service/UserService.java | 5 +++ .../db/migration/V8__jambotron_admin.sql | 3 ++ .../controller/ServiceControllerTest.java | 29 ++++++++++++++--- 7 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/config/MailAdminsLoader.java create mode 100644 backend/src/main/resources/db/migration/V8__jambotron_admin.sql diff --git a/backend/src/main/java/com/mailserver/config/MailAdmins.java b/backend/src/main/java/com/mailserver/config/MailAdmins.java index 2c48d5a..14f24f5 100644 --- a/backend/src/main/java/com/mailserver/config/MailAdmins.java +++ b/backend/src/main/java/com/mailserver/config/MailAdmins.java @@ -10,13 +10,15 @@ import java.util.stream.Collectors; /** * Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first - * mailbox — {@code mailserver.admin.email}, or admin@<first domain>. No role in the database: the list lives in - * the configuration, so a stolen mailbox can not make itself an administrator. + * mailbox — {@code mailserver.admin.email}, or admin@<first domain>; plus mailboxes Jambotron marks as admin + * through the service API ({@code users.is_admin}, V8 — every Jambotron ADMIN). A mailbox can not make itself an + * administrator: only the configuration or the service token. */ @Component public class MailAdmins { private final Set admins; + private final Set granted = java.util.concurrent.ConcurrentHashMap.newKeySet(); public MailAdmins(@Value("${mailserver.admins:}") String admins, @Value("${mailserver.admin.email:}") String firstMailbox, @@ -31,6 +33,20 @@ public class MailAdmins { } public boolean isAdmin(String email) { - return email != null && admins.contains(email.toLowerCase(Locale.ROOT)); + if (email == null) { + return false; + } + String key = email.toLowerCase(Locale.ROOT); + return admins.contains(key) || granted.contains(key); + } + + /** Given / taken by Jambotron (users.is_admin) — loaded at start and kept here on every change. */ + public void grant(String email, boolean admin) { + String key = email.toLowerCase(Locale.ROOT); + if (admin) { + granted.add(key); + } else { + granted.remove(key); + } } } diff --git a/backend/src/main/java/com/mailserver/config/MailAdminsLoader.java b/backend/src/main/java/com/mailserver/config/MailAdminsLoader.java new file mode 100644 index 0000000..325b36a --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/MailAdminsLoader.java @@ -0,0 +1,32 @@ +package com.mailserver.config; + +import com.mailserver.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** At start: mailboxes Jambotron made administrators (users.is_admin) → {@link MailAdmins}. */ +@Component +public class MailAdminsLoader { + + private static final Logger log = LoggerFactory.getLogger(MailAdminsLoader.class); + + private final UserRepository users; + private final MailAdmins admins; + + public MailAdminsLoader(UserRepository users, MailAdmins admins) { + this.users = users; + this.admins = admins; + } + + @EventListener(ApplicationReadyEvent.class) + public void load() { + users.findAll().filter(user -> user.isAdmin()) + .doOnNext(user -> admins.grant(user.getEmail(), true)) + .count() + .subscribe(n -> log.info("Mail administrators from Jambotron: {}", n), + e -> log.error("Mail administrators from Jambotron not loaded: {}", e.getMessage())); + } +} diff --git a/backend/src/main/java/com/mailserver/controller/ServiceController.java b/backend/src/main/java/com/mailserver/controller/ServiceController.java index be492af..3104ed5 100644 --- a/backend/src/main/java/com/mailserver/controller/ServiceController.java +++ b/backend/src/main/java/com/mailserver/controller/ServiceController.java @@ -40,10 +40,11 @@ public class ServiceController { private static final Logger log = LoggerFactory.getLogger(ServiceController.class); /** @param displayName null — unchanged (new account: the name before @) */ - public record AccountRequest(@Size(max = 100) String displayName, Boolean active) { + /** @param admin mail administrator (Jambotron ADMIN); null — leave as it is */ + public record AccountRequest(@Size(max = 100) String displayName, Boolean active, Boolean admin) { } - public record AccountView(String email, String displayName, boolean active, List lists) { + public record AccountView(String email, String displayName, boolean active, boolean admin, List lists) { } /** @param password generated, only when the account was just created or got a new password */ @@ -59,10 +60,12 @@ public class ServiceController { private final MailLists lists; private final MailDirectory directory; private final MailboxAccess access; + private final com.mailserver.config.MailAdmins admins; public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists, - MailDirectory directory, MailboxAccess access) { + MailDirectory directory, MailboxAccess access, com.mailserver.config.MailAdmins admins) { this.access = access; + this.admins = admins; this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8); this.users = users; this.lists = lists; @@ -84,13 +87,14 @@ public class ServiceController { @PathVariable String email, @RequestBody Mono body) { check(auth); String address = directory.address(email); - return body.defaultIfEmpty(new AccountRequest(null, null)).flatMap(request -> users.findOptional(address) + return body.defaultIfEmpty(new AccountRequest(null, null, null)).flatMap(request -> users.findOptional(address) .flatMap(user -> update(user, request).flatMap(this::view) .map(view -> ResponseEntity.ok(new AccountResult(view, null)))) .switchIfEmpty(Mono.defer(() -> { String password = MailDirectory.randomPassword(); return directory.createMailbox(address, request.displayName(), password) - .flatMap(user -> Boolean.FALSE.equals(request.active()) ? update(user, request) : Mono.just(user)) + .flatMap(user -> Boolean.FALSE.equals(request.active()) || Boolean.TRUE.equals(request.admin()) + ? update(user, request) : Mono.just(user)) .doOnNext(user -> log.info("Mailbox {} created by Jambotron", user.getEmail())) .flatMap(this::view) .map(view -> ResponseEntity.status(HttpStatus.CREATED).body(new AccountResult(view, password))); @@ -152,8 +156,14 @@ public class ServiceController { } private Mono update(User user, AccountRequest request) { - Mono named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user) + Mono renamed = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user) : users.updateDisplayName(user, request.displayName()); + Mono named = request.admin() == null || request.admin() == user.isAdmin() ? renamed + : renamed.flatMap(u -> users.setAdmin(u, request.admin())) + .doOnNext(u -> { + admins.grant(u.getEmail(), u.isAdmin()); + log.info("Mailbox {} {} mail administrator by Jambotron", u.getEmail(), u.isAdmin() ? "made" : "is no longer"); + }); if (request.active() == null || request.active() == user.isActive()) { return named; } @@ -166,7 +176,7 @@ public class ServiceController { private Mono view(User user) { return lists.listsOf(user.getId()).collectList() - .map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), in)); + .map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), admins.isAdmin(user.getEmail()), in)); } /** 404 when the API is off, 401 for a wrong token (constant-time compare). */ diff --git a/backend/src/main/java/com/mailserver/domain/User.java b/backend/src/main/java/com/mailserver/domain/User.java index 6c6bf62..a4fcd3d 100644 --- a/backend/src/main/java/com/mailserver/domain/User.java +++ b/backend/src/main/java/com/mailserver/domain/User.java @@ -34,6 +34,10 @@ public class User { @Column("is_trap") private boolean isTrap = false; + /** Mail administrator given by Jambotron through the service API (V8); plus mailserver.admins. */ + @Column("is_admin") + private boolean isAdmin = false; + /** Shared mailbox (V7): no login of its own, members open it. */ @Column("is_shared") private boolean isShared = false; @@ -64,6 +68,8 @@ public class User { public boolean isTrap() { return isTrap; } public void setTrap(boolean trap) { isTrap = trap; } + public boolean isAdmin() { return isAdmin; } + public void setAdmin(boolean admin) { isAdmin = admin; } public boolean isShared() { return isShared; } public void setShared(boolean shared) { isShared = shared; } diff --git a/backend/src/main/java/com/mailserver/service/UserService.java b/backend/src/main/java/com/mailserver/service/UserService.java index f7da252..d30e4a7 100644 --- a/backend/src/main/java/com/mailserver/service/UserService.java +++ b/backend/src/main/java/com/mailserver/service/UserService.java @@ -103,6 +103,11 @@ public class UserService { return userRepository.save(user); } + public Mono setAdmin(User user, boolean admin) { + user.setAdmin(admin); + return userRepository.save(user); + } + public Mono setTrap(User user, boolean trap) { user.setTrap(trap); return userRepository.save(user); diff --git a/backend/src/main/resources/db/migration/V8__jambotron_admin.sql b/backend/src/main/resources/db/migration/V8__jambotron_admin.sql new file mode 100644 index 0000000..8e4f872 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__jambotron_admin.sql @@ -0,0 +1,3 @@ +-- Mail administrator given by Jambotron (service API): every ADMIN of Jambotron manages mailboxes here too. +-- Only the service token sets it — a mailbox can not make itself an administrator. Plus mailserver.admins. +ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java b/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java index 2a781e5..97811d2 100644 --- a/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java +++ b/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java @@ -34,6 +34,7 @@ class ServiceControllerTest { private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class); private final MailDirectory directory = new MailDirectory(users, lists, domains, access); private final User existing = user(7L, "jambotronpw@jambocheck.com"); + private final com.mailserver.config.MailAdmins admins = new com.mailserver.config.MailAdmins("", "", domains); ServiceControllerTest() { when(users.findOptional(anyString())).thenReturn(Mono.empty()); @@ -49,10 +50,30 @@ class ServiceControllerTest { return Mono.just(u); }); when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0))); + when(users.setAdmin(any(), any(Boolean.class))).thenAnswer(i -> { + User u = i.getArgument(0); + u.setAdmin(i.getArgument(1)); + return Mono.just(u); + }); + } + + // Jambotron ADMIN → mail administrator (Settings → MAILBOXES), and back + @Test + void jambotronMakesAMailboxAdministratorAndTakesItBack() { + assertThat(admins.isAdmin("jambotronpw@jambocheck.com")).isFalse(); + var made = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com", + Mono.just(new ServiceController.AccountRequest(null, true, true))).block(); + assertThat(made.getBody().account().admin()).isTrue(); + assertThat(admins.isAdmin("JambotronPW@jambocheck.com")).isTrue(); + + api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com", + Mono.just(new ServiceController.AccountRequest(null, true, false))).block(); + assertThat(admins.isAdmin("jambotronpw@jambocheck.com")).isFalse(); + assertThat(admins.isAdmin("admin@jambocheck.com")).isTrue(); } private ServiceController api(String token) { - return new ServiceController(token, users, lists, directory, access); + return new ServiceController(token, users, lists, directory, access, admins); } @Test @@ -67,12 +88,12 @@ class ServiceControllerTest { @Test void newAccountGetsPasswordOnceExistingDoesNot() { var created = api("s3cret-token").saveAccount(AUTH, "moder@jambocheck.com", - Mono.just(new ServiceController.AccountRequest("Moder", true))).block(); + Mono.just(new ServiceController.AccountRequest("Moder", true, null))).block(); assertThat(created.getStatusCode().value()).isEqualTo(201); assertThat(created.getBody().password()).hasSizeGreaterThan(20); var again = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com", - Mono.just(new ServiceController.AccountRequest(null, null))).block(); + Mono.just(new ServiceController.AccountRequest(null, null, null))).block(); assertThat(again.getStatusCode().value()).isEqualTo(200); assertThat(again.getBody().password()).isNull(); verify(lists, never()).removeEverywhere(anyLong()); @@ -83,7 +104,7 @@ class ServiceControllerTest { @Test void switchedOffLeavesEveryList() { var off = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com", - Mono.just(new ServiceController.AccountRequest(null, false))).block(); + Mono.just(new ServiceController.AccountRequest(null, false, null))).block(); assertThat(off.getBody().account().active()).isFalse(); verify(lists).removeEverywhere(7L); verify(access).removeEverywhere(7L); -- 2.54.0