IdServer.java

/*
 * Copyright © 2023 Mark Raynsford <code@io7m.com> https://www.io7m.com
 *
 * Permission to use, copy, modify, and/or distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
 * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
 * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

package com.io7m.idstore.server.vanilla.internal;

import com.io7m.idstore.database.api.IdDatabaseAdminsQueriesType;
import com.io7m.idstore.database.api.IdDatabaseException;
import com.io7m.idstore.database.api.IdDatabaseTelemetry;
import com.io7m.idstore.database.api.IdDatabaseType;
import com.io7m.idstore.error_codes.IdErrorCode;
import com.io7m.idstore.model.IdEmail;
import com.io7m.idstore.model.IdName;
import com.io7m.idstore.model.IdPasswordAlgorithmPBKDF2HmacSHA256;
import com.io7m.idstore.model.IdPasswordException;
import com.io7m.idstore.model.IdRealName;
import com.io7m.idstore.protocol.admin.cb.IdACB1Messages;
import com.io7m.idstore.protocol.user.cb.IdUCB1Messages;
import com.io7m.idstore.server.admin_v1.IdA1Server;
import com.io7m.idstore.server.api.IdServerConfiguration;
import com.io7m.idstore.server.api.IdServerException;
import com.io7m.idstore.server.api.IdServerType;
import com.io7m.idstore.server.controller.admin.IdAdminLoginService;
import com.io7m.idstore.server.controller.user.IdUserLoginService;
import com.io7m.idstore.server.controller.user_pwreset.IdUserPasswordResetService;
import com.io7m.idstore.server.controller.user_pwreset.IdUserPasswordResetServiceType;
import com.io7m.idstore.server.service.branding.IdServerBrandingService;
import com.io7m.idstore.server.service.branding.IdServerBrandingServiceType;
import com.io7m.idstore.server.service.clock.IdServerClock;
import com.io7m.idstore.server.service.configuration.IdServerConfigurationService;
import com.io7m.idstore.server.service.health.IdServerHealth;
import com.io7m.idstore.server.service.mail.IdServerMailService;
import com.io7m.idstore.server.service.mail.IdServerMailServiceType;
import com.io7m.idstore.server.service.maintenance.IdClosedForMaintenanceService;
import com.io7m.idstore.server.service.maintenance.IdMaintenanceService;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitAdminLoginService;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitAdminLoginServiceType;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitEmailVerificationService;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitEmailVerificationServiceType;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitPasswordResetService;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitPasswordResetServiceType;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitUserLoginService;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitUserLoginServiceType;
import com.io7m.idstore.server.service.reqlimit.IdRequestLimits;
import com.io7m.idstore.server.service.sessions.IdSessionAdminService;
import com.io7m.idstore.server.service.sessions.IdSessionUserService;
import com.io7m.idstore.server.service.telemetry.api.IdEventService;
import com.io7m.idstore.server.service.telemetry.api.IdEventServiceType;
import com.io7m.idstore.server.service.telemetry.api.IdMetricsService;
import com.io7m.idstore.server.service.telemetry.api.IdMetricsServiceType;
import com.io7m.idstore.server.service.telemetry.api.IdServerTelemetryNoOp;
import com.io7m.idstore.server.service.telemetry.api.IdServerTelemetryServiceFactoryType;
import com.io7m.idstore.server.service.telemetry.api.IdServerTelemetryServiceType;
import com.io7m.idstore.server.service.templating.IdFMTemplateService;
import com.io7m.idstore.server.service.templating.IdFMTemplateServiceType;
import com.io7m.idstore.server.service.verdant.IdVerdantMessages;
import com.io7m.idstore.server.user_v1.IdU1Server;
import com.io7m.idstore.server.user_view.IdUVServer;
import com.io7m.idstore.strings.IdStrings;
import com.io7m.jmulticlose.core.CloseableCollection;
import com.io7m.jmulticlose.core.CloseableCollectionType;
import com.io7m.repetoir.core.RPServiceDirectory;
import com.io7m.repetoir.core.RPServiceDirectoryType;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
import org.eclipse.jetty.server.Server;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;

import static com.io7m.idstore.database.api.IdDatabaseRole.IDSTORE;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.ADMIN_NOT_INITIAL;
import static com.io7m.idstore.server.service.telemetry.api.IdServerTelemetryServiceType.recordSpanException;
import static java.util.concurrent.TimeUnit.SECONDS;

/**
 * The internal server frontend.
 */

public final class IdServer implements IdServerType
{
  private static final Logger LOG =
    LoggerFactory.getLogger(IdServer.class);

  private final IdServerConfiguration configuration;
  private final AtomicBoolean stopped;
  private CloseableCollectionType<IdServerException> resources;
  private IdServerTelemetryServiceType telemetry;
  private IdDatabaseType database;

  /**
   * The internal server frontend.
   *
   * @param inConfiguration The server configuration
   */

  public IdServer(
    final IdServerConfiguration inConfiguration)
  {
    this.configuration =
      Objects.requireNonNull(inConfiguration, "configuration");
    this.resources =
      createResourceCollection();
    this.stopped =
      new AtomicBoolean(true);
  }

  private static CloseableCollectionType<IdServerException> createResourceCollection()
  {
    return CloseableCollection.create(
      () -> {
        return new IdServerException(
          "Server creation failed.",
          new IdErrorCode("server-creation"),
          Map.of(),
          Optional.empty()
        );
      }
    );
  }

  @Override
  public void start()
    throws IdServerException
  {
    try {
      if (this.stopped.compareAndSet(true, false)) {
        this.resources = createResourceCollection();
        this.telemetry = this.createTelemetry();

        final var startupSpan =
          this.telemetry.tracer()
            .spanBuilder("IdServer.start")
            .setSpanKind(SpanKind.INTERNAL)
            .startSpan();

        try (var ignored = startupSpan.makeCurrent()) {
          this.startInSpan();
        } finally {
          startupSpan.end();
        }
      }
    } catch (final Throwable e) {
      this.close();
      throw e;
    }
  }

  private void startInSpan()
    throws IdServerException
  {
    try {
      final var dbTelemetry =
        new IdDatabaseTelemetry(
          this.telemetry.isNoOp(),
          this.telemetry.meter(),
          this.telemetry.tracer()
        );

      this.database =
        this.resources.add(this.createDatabase(dbTelemetry));
      final var services =
        this.resources.add(this.createServiceDirectory(this.database));

      final Server userView = IdUVServer.createUserViewServer(services);
      this.resources.add(userView::stop);

      final Server userAPI = IdU1Server.createUserAPIServer(services);
      this.resources.add(userAPI::stop);

      final Server adminAPI = IdA1Server.createAdminAPIServer(services);
      this.resources.add(adminAPI::stop);
    } catch (final IdDatabaseException e) {
      recordSpanException(e);

      try {
        this.close();
      } catch (final IdServerException ex) {
        e.addSuppressed(ex);
      }
      throw new IdServerException(
        e.getMessage(),
        e,
        new IdErrorCode("database"),
        Map.of(),
        Optional.empty()
      );
    } catch (final Exception e) {
      recordSpanException(e);

      try {
        this.close();
      } catch (final IdServerException ex) {
        e.addSuppressed(ex);
      }
      throw new IdServerException(
        e.getMessage(),
        e,
        new IdErrorCode("startup"),
        Map.of(),
        Optional.empty()
      );
    }
  }

  private RPServiceDirectoryType createServiceDirectory(
    final IdDatabaseType newDatabase)
    throws IOException
  {
    final var services = new RPServiceDirectory();
    services.register(IdServerTelemetryServiceType.class, this.telemetry);
    services.register(IdDatabaseType.class, newDatabase);

    final var metrics = new IdMetricsService(this.telemetry);
    services.register(IdMetricsServiceType.class, metrics);

    services.register(
      IdClosedForMaintenanceService.class,
      new IdClosedForMaintenanceService(metrics)
    );

    final var eventService = IdEventService.create(this.telemetry, metrics);
    services.register(IdEventServiceType.class, eventService);

    final var strings = IdStrings.create(this.configuration.locale());
    services.register(IdStrings.class, strings);

    final var mailService =
      IdServerMailService.create(
        this.telemetry,
        eventService,
        this.configuration.mailConfiguration()
      );
    services.register(IdServerMailServiceType.class, mailService);

    final var sessionAdminService =
      new IdSessionAdminService(
        metrics,
        this.configuration.sessions().adminSessionExpiration()
      );
    services.register(IdSessionAdminService.class, sessionAdminService);

    final var sessionUserService =
      new IdSessionUserService(
        metrics,
        this.configuration.sessions().userSessionExpiration()
      );
    services.register(IdSessionUserService.class, sessionUserService);

    final var config =
      new IdServerConfigurationService(metrics, this.configuration);
    services.register(IdServerConfigurationService.class, config);

    final var clock = new IdServerClock(this.configuration.clock());
    services.register(IdServerClock.class, clock);

    final var userLoginRateLimitService =
      IdRateLimitUserLoginService.create(
        metrics,
        this.configuration.rateLimit()
          .userLoginRateLimit()
          .toSeconds(),
        SECONDS
      );

    services.register(
      IdRateLimitUserLoginServiceType.class,
      userLoginRateLimitService
    );

    final var adminLoginRateLimitService =
      IdRateLimitAdminLoginService.create(
        metrics,
        this.configuration.rateLimit()
          .userLoginRateLimit()
          .toSeconds(),
        SECONDS
      );

    services.register(
      IdRateLimitAdminLoginServiceType.class,
      adminLoginRateLimitService
    );

    services.register(
      IdUserLoginService.class,
      new IdUserLoginService(
        clock,
        strings,
        sessionUserService,
        config,
        userLoginRateLimitService,
        eventService
      )
    );

    services.register(
      IdAdminLoginService.class,
      new IdAdminLoginService(
        clock,
        strings,
        sessionAdminService,
        adminLoginRateLimitService,
        eventService
      )
    );

    final var templates = IdFMTemplateService.create();
    services.register(IdFMTemplateServiceType.class, templates);

    final var brandingService =
      IdServerBrandingService.create(templates, this.configuration.branding());
    services.register(IdServerBrandingServiceType.class, brandingService);

    final var vMessages = new IdVerdantMessages();
    services.register(IdVerdantMessages.class, vMessages);

    final var idA1Messages = new IdACB1Messages();
    services.register(IdACB1Messages.class, idA1Messages);

    final var idU1Messages = new IdUCB1Messages();
    services.register(IdUCB1Messages.class, idU1Messages);

    final var userPasswordRateLimitService =
      IdRateLimitPasswordResetService.create(
        metrics,
        this.configuration.rateLimit()
          .passwordResetRateLimit()
          .toSeconds(),
        SECONDS
      );

    services.register(
      IdRateLimitPasswordResetServiceType.class,
      userPasswordRateLimitService
    );

    final var emailVerificationRateLimitService =
      IdRateLimitEmailVerificationService.create(
        metrics,
        this.configuration.rateLimit()
          .emailVerificationRateLimit()
          .toSeconds(),
        SECONDS
      );

    services.register(
      IdRateLimitEmailVerificationServiceType.class,
      emailVerificationRateLimitService
    );

    final var userPasswordResetService =
      IdUserPasswordResetService.create(
        this.telemetry,
        brandingService,
        templates,
        mailService,
        this.configuration,
        clock,
        this.database,
        strings,
        userPasswordRateLimitService,
        eventService
      );
    services.register(
      IdUserPasswordResetServiceType.class,
      userPasswordResetService
    );

    final var health = IdServerHealth.create(services);
    services.register(IdServerHealth.class, health);

    final var maintenance =
      IdMaintenanceService.create(clock, this.telemetry, newDatabase);
    services.register(IdMaintenanceService.class, maintenance);

    services.register(IdRequestLimits.class, new IdRequestLimits(size -> {
      return strings.format("requestTooLarge", size);
    }));

    for (final var service : services.services()) {
      LOG.debug("{} {}", service, service.description());
    }
    return services;
  }

  private IdDatabaseType createDatabase(
    final IdDatabaseTelemetry dbTelemetry)
    throws IdDatabaseException
  {
    return this.configuration.databases()
      .open(
        this.configuration.databaseConfiguration(),
        dbTelemetry,
        event -> {

        });
  }

  private IdServerTelemetryServiceType createTelemetry()
  {
    return this.configuration.openTelemetry()
      .flatMap(config -> {
        final var loader =
          ServiceLoader.load(IdServerTelemetryServiceFactoryType.class);
        return loader.findFirst().map(f -> f.create(config));
      }).orElseGet(IdServerTelemetryNoOp::noop);
  }

  @Override
  public IdDatabaseType database()
  {
    if (this.stopped.get()) {
      throw new IllegalStateException("Server is not started.");
    }

    return this.database;
  }

  @Override
  public boolean isClosed()
  {
    return this.stopped.get();
  }

  @Override
  public void close()
    throws IdServerException
  {
    if (this.stopped.compareAndSet(false, true)) {
      this.resources.close();
    }
  }

  @Override
  public IdServerConfiguration configuration()
  {
    return this.configuration;
  }

  @Override
  public void createOrUpdateInitialAdmin(
    final UUID adminId,
    final IdName adminName,
    final IdEmail adminEmail,
    final IdRealName adminRealName,
    final String adminPassword)
    throws IdServerException
  {
    Objects.requireNonNull(adminId, "adminId");
    Objects.requireNonNull(adminName, "adminName");
    Objects.requireNonNull(adminEmail, "adminEmail");
    Objects.requireNonNull(adminRealName, "adminRealName");
    Objects.requireNonNull(adminPassword, "adminPassword");

    final var newTelemetry =
      this.createTelemetry();

    final var dbTelemetry =
      new IdDatabaseTelemetry(
        newTelemetry.isNoOp(),
        newTelemetry.meter(),
        newTelemetry.tracer()
      );

    final var dbConfiguration =
      this.configuration.databaseConfiguration()
        .withoutUpgradeOrCreate();

    try (var newDatabase =
           this.configuration.databases()
             .open(dbConfiguration, dbTelemetry, event -> {

             })) {

      final var span =
        newTelemetry.tracer()
          .spanBuilder("CreateOrUpdateInitialAdmin")
          .startSpan();

      try (var ignored = span.makeCurrent()) {
        createOrUpdateInitialAdminSpan(
          newDatabase,
          adminId,
          adminName,
          adminEmail,
          adminRealName,
          adminPassword
        );
      } catch (final Exception e) {
        span.recordException(e);
        span.setStatus(StatusCode.ERROR);
        throw e;
      } finally {
        span.end();
      }
    } catch (final IdDatabaseException e) {
      throw new IdServerException(
        e.getMessage(),
        e,
        e.errorCode(),
        e.attributes(),
        e.remediatingAction()
      );
    }
  }

  private static void createOrUpdateInitialAdminSpan(
    final IdDatabaseType database,
    final UUID adminId,
    final IdName adminName,
    final IdEmail adminEmail,
    final IdRealName adminRealName,
    final String adminPassword)
    throws IdServerException
  {
    try (var connection = database.openConnection(IDSTORE)) {
      try (var transaction = connection.openTransaction()) {
        final var admins =
          transaction.queries(IdDatabaseAdminsQueriesType.class);

        final var hashedPassword =
          IdPasswordAlgorithmPBKDF2HmacSHA256.create()
            .createHashed(adminPassword);

        try {
          admins.adminCreateInitial(
            adminId,
            adminName,
            adminRealName,
            adminEmail,
            OffsetDateTime.now(),
            hashedPassword
          );
        } catch (final IdDatabaseException e) {
          if (Objects.equals(e.errorCode(), ADMIN_NOT_INITIAL)) {
            LOG.info(
              "The initial admin already exists; updating password instead.");
            admins.adminUpdateInitial(
              adminId,
              Optional.of(adminName),
              Optional.of(adminRealName),
              Optional.of(hashedPassword)
            );
          } else {
            throw e;
          }
        }

        transaction.commit();
      }
    } catch (final IdDatabaseException | IdPasswordException e) {
      throw new IdServerException(
        e.getMessage(),
        e,
        e.errorCode(),
        e.attributes(),
        e.remediatingAction()
      );
    }
  }

  @Override
  public String toString()
  {
    return "[IdServer 0x%s]"
      .formatted(Integer.toUnsignedString(this.hashCode(), 16));
  }
}