blob: 011345df9f6c4cacb54628f482161feab6b12e27 [file] [log] [blame]
/****************************************************************
* Licensed to the Apache Software Foundation (ASF) under one *
* or more contributor license agreements. See the NOTICE file *
* distributed with this work for additional information *
* regarding copyright ownership. The ASF licenses this file *
* to you under the Apache License, Version 2.0 (the *
* "License"); you may not use this file except in compliance *
* with the License. You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, *
* software distributed under the License is distributed on an *
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
* KIND, either express or implied. See the License for the *
* specific language governing permissions and limitations *
* under the License. *
****************************************************************/
package org.apache.james.jmap.api.model
import java.net.{URI, URL}
import java.security.interfaces.ECPublicKey
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.{Base64, UUID}
import com.google.crypto.tink.HybridEncrypt
import com.google.crypto.tink.apps.webpush.WebPushHybridEncrypt
import com.google.crypto.tink.subtle.EllipticCurves
import org.apache.james.jmap.api.model.ExpireTimeInvalidException.TIME_FORMATTER
import scala.util.Try
object PushSubscriptionId {
def generate(): PushSubscriptionId = PushSubscriptionId(UUID.randomUUID)
def parse(string: String): Either[IllegalArgumentException, PushSubscriptionId] = Try(UUID.fromString(string)).toEither
.map(PushSubscriptionId(_))
.left.map {
case e: IllegalArgumentException => e
case e => new IllegalArgumentException(e)
}
}
case class PushSubscriptionId(value: UUID) {
def serialise: String = value.toString
}
case class DeviceClientId(value: String) extends AnyVal
object VerificationCode {
def generate(): VerificationCode = VerificationCode(UUID.randomUUID().toString)
}
case class VerificationCode(value: String) extends AnyVal
object PushSubscriptionServerURL {
def from(value: String): Try[PushSubscriptionServerURL] = Try(PushSubscriptionServerURL(new URI(value).toURL))
}
case class PushSubscriptionServerURL(value: URL)
case class PushSubscriptionExpiredTime(value: ZonedDateTime) {
def isAfter(date: ZonedDateTime): Boolean = value.isAfter(date)
def isBefore(date: ZonedDateTime): Boolean = value.isBefore(date)
}
case class PushSubscriptionKeys(p256dh: String, auth: String) {
def validate: Either[IllegalArgumentException, PushSubscriptionKeys] =
Try(asHybridEncrypt()).map(_ => this)
.toEither
.left.map {
case e: IllegalArgumentException => e
case e => new IllegalArgumentException(e)
}
// Follows https://datatracker.ietf.org/doc/html/rfc8291
// Message Encryption for Web Push
def encrypt(payload: Array[Byte]): Array[Byte] = asHybridEncrypt()
.encrypt(payload, null)
private def asHybridEncrypt(): HybridEncrypt = new WebPushHybridEncrypt.Builder()
.withAuthSecret(Base64.getUrlDecoder().decode(auth))
.withRecipientPublicKey(asECPublicKey())
.build()
private def asECPublicKey(): ECPublicKey = EllipticCurves.getEcPublicKey(Base64.getUrlDecoder.decode(p256dh))
}
object PushSubscriptionCreationRequest {
val noTypes: Seq[TypeName] = Seq()
}
case class PushSubscriptionCreationRequest(deviceClientId: DeviceClientId,
url: PushSubscriptionServerURL,
keys: Option[PushSubscriptionKeys] = None,
expires: Option[PushSubscriptionExpiredTime] = None,
types: Seq[TypeName]) {
def validate: Either[IllegalArgumentException, PushSubscriptionCreationRequest] =
for {
_ <- validateTypes
_ <- validateKeys
} yield {
this
}
private def validateTypes: Either[IllegalArgumentException, PushSubscriptionCreationRequest] =
if (types.isEmpty) {
scala.Left(new IllegalArgumentException("types must not be empty"))
} else {
Right(this)
}
private def validateKeys: Either[IllegalArgumentException, PushSubscriptionCreationRequest] =
keys.map(_.validate.map(_ => this))
.getOrElse(Right(this))
}
object PushSubscription {
val VALIDATED: Boolean = true
val EXPIRES_TIME_MAX_DAY: Int = 7
def from(creationRequest: PushSubscriptionCreationRequest,
expireTime: PushSubscriptionExpiredTime): PushSubscription =
PushSubscription(id = PushSubscriptionId.generate(),
deviceClientId = creationRequest.deviceClientId,
url = creationRequest.url,
keys = creationRequest.keys,
verificationCode = VerificationCode.generate(),
validated = !VALIDATED,
expires = expireTime,
types = creationRequest.types)
}
case class PushSubscription(id: PushSubscriptionId,
deviceClientId: DeviceClientId,
url: PushSubscriptionServerURL,
keys: Option[PushSubscriptionKeys],
verificationCode: VerificationCode,
validated: Boolean,
expires: PushSubscriptionExpiredTime,
types: Seq[TypeName]) {
def withTypes(types: Seq[TypeName]): PushSubscription = copy(types = types)
def verified(): PushSubscription = copy(validated = true)
def withExpires(expires: PushSubscriptionExpiredTime): PushSubscription = copy(expires = expires)
}
case class PushSubscriptionNotFoundException(id: PushSubscriptionId) extends RuntimeException
object ExpireTimeInvalidException {
val TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssX")
}
case class ExpireTimeInvalidException(expires: ZonedDateTime, message: String) extends IllegalStateException(s"`${expires.format(TIME_FORMATTER)}` $message")
case class DeviceClientIdInvalidException(deviceClientId: DeviceClientId, message: String) extends IllegalArgumentException(s"`${deviceClientId.value}` $message")
case class InvalidPushSubscriptionKeys(keys: PushSubscriptionKeys) extends IllegalArgumentException