<?php
namespace App\Entity;
use AdScore\Common\Gearboy\Gearboy;
use AdScore\Common\Definition\Apikey as ApikeyDefinition;
use AdScore\Common\Crypt\{
Asymmetric\OpenSSL as AsymmOpenSSL,
Symmetric\AbstractSymmetricCrypt,
CryptFactory
};
use AdScore\Common\Struct\{
AbstractStruct,
StructFactory
};
use AdScore\Common\StrUtils;
use AdScore\Traffic\Utils\Request as TrafficRequest;
use App\Component\Result;
use App\HelperFunctions;
use DateTime;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use RuntimeException;
use InvalidArgumentException;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* Zone
*
* @ORM\Table(name="zones", indexes={@ORM\Index(name="account_id", columns={"account_id"}), @ORM\Index(name="status", columns={"status"})})
* @ORM\Entity
*/
class Zone extends Entity
{
public const SERIALIZATION_CONTEXT = [
'list' => ['groups' => ['general']],
];
public const TRAFFIC_OPTIONS_DEFAULTS = [
'enable_subid_anomaly_detection' => true,
'allow_good_bots' => false,
'allow_google_scanners' => false,
'enable_anti_piracy_compliance' => false,
'allow_lite_processing' => false,
'iframe_handling' => 'auto', /* auto, deny */
'subid_source' => 'parameter', /* parameter, location_domain, referrer_domain, location_param_utm_source, location_param_utm_campaign, location_param_utm_medium */
'enable_compliance_intelligence' => true,
'enable_referrer_blacklisting' => true,
'mobile_request_as_desktop_traffic' => true
];
const DEFAULT_RESPONSE_AUTH = 'hash_sha256';
/**
* @var int
*
* @ORM\Column(name="id", type="bigint", nullable=false, options={"unsigned"=true})
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @Serializer\Groups({"general", "account_admin_details"})
*/
protected $id;
/**
* @var DateTime
*
* @ORM\Column(name="created", type="datetime", nullable=false)
* @Serializer\Groups({"general"})
*/
protected $created = null;
/**
* @var string
*
* @ORM\Column(name="status", type="string", length=0, nullable=false, options={"default"="active"})
* @Serializer\Groups({"general"})
*/
protected $status = self::STATUS['active'];
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
* @Serializer\Groups({"general", "account_admin_details"})
*/
protected $name;
/**
* @var string
*
* @ORM\Column(name="apikey_key", type="string", length=64, nullable=false)
*/
protected $apikeyKey;
/**
* @var string|null
*
* @ORM\Column(name="request_auth", type="string", length=0, nullable=true)
* @Serializer\Groups({"general"})
*/
protected $requestAuth = null;
/**
* @var string|null
*
* @ORM\Column(name="request_pass", type="string", length=64, nullable=true)
*/
protected $requestPass;
/**
* @var string|null
*
* @ORM\Column(name="request_key", type="string", length=255, nullable=true)
*/
protected $requestKey;
/**
* @var array|null
*
* @ORM\Column(name="request_referrer", type="line_separated_values", length=65535, nullable=true)
* @Serializer\Groups({"general"})
*/
protected $requestReferrer = null;
/**
* @var string
*
* @ORM\Column(name="response_auth", type="string", length=0, nullable=true)
* @Serializer\Groups({"general"})
*/
protected $responseAuth = self::DEFAULT_RESPONSE_AUTH;
/**
* @var string|null
*
* @ORM\Column(name="response_key", type="string", length=255, nullable=true)
*/
protected $responseKey;
/**
* @var string|null
*
* @ORM\Column(name="external_sub_id_url", type="string", length=255, nullable=true)
* @Serializer\Groups({"general"})
*/
protected $externalSubIdUrl = null;
/**
* @var string|null
*
* @ORM\Column(name="result_source", type="string", length=64, nullable=true)
* @Serializer\Groups({"general"})
*/
protected $resultSource;
/**
* @var object|null
*
* @ORM\Column(name="traffic_options", type="json", nullable=true)
*/
protected $trafficOptions = self::TRAFFIC_OPTIONS_DEFAULTS;
/**
* @var Account|null
*
* @ORM\ManyToOne(targetEntity="Account")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="account_id", referencedColumnName="id")
* })
*/
protected $account;
/**
* @var ArrayCollection
*
* @ORM\OneToMany(targetEntity="GwocDomain", mappedBy="account")
*/
protected $gwocDomains;
/**
* @var bool
*/
protected $hasSubId = true;
/**
* @var bool
*/
protected $hasTraffic = true;
/**
* @var array
* @Serializer\Ignore()
*/
private $liveData;
/**
* @var array|false
*/
private $ltam;
/**
* @var string|null
* @ORM\Column(type="string", length=255)
* @Serializer\Groups({"general"})
*/
private $customFields;
public function __construct(int $userId, Account $account)
{
$this->gwocDomains = new ArrayCollection();
$this->frontInvoker()->privilegeCheck($userId, $account->getId(), 'owner');
$this->setAccount($account);
$this->apikeyKey = random_bytes(32);
$this->requestKey = random_bytes(32);
$this->requestPass = random_bytes(32);
$this->generateResponseKey();
$this->created = new DateTime();
}
/**
* @param int $id
*/
public function setId(int $id): void
{
$this->id = $id;
}
public function getId(): ?int
{
return $this->id;
}
public function getCreated(): ?DateTimeInterface
{
return $this->created;
}
public function setCreated(DateTimeInterface $created): self
{
$this->created = $created;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = trim($name);
return $this;
}
/**
* This is the "apikey key" (sic!) - not the apikey used by the app, but an intermediate seed used to build and verify API key
* @return string|null
* @Serializer\Ignore
*/
public function getApikeyKey(): ?string
{
return $this->apikeyKey;
}
/**
* @return string|null
* @Serializer\Ignore
*/
public function getEncodedApikeyKey(): ?string
{
return StrUtils::toBase64($this->apikeyKey);
}
public function generateApikey(): string {
$ak = new ApikeyDefinition($this->id, $this->apikeyKey);
return $ak->toString();
}
/**
* This is the "apikey key" (sic!) - not the apikey used by the app, but an intermediate seed used to build and verify API key
* @param string $apikeyKey
* @return self
*/
public function setApikeyKey(string $apikeyKey): self
{
$this->apikeyKey = $apikeyKey;
return $this;
}
public function getRequestAuth(): ?string
{
return $this->requestAuth;
}
public function setRequestAuth(?string $requestAuth): self
{
$this->requestAuth = $requestAuth;
return $this;
}
/**
* @return string|null
* @Serializer\Ignore
*/
public function getRequestPass(): ?string
{
return $this->requestPass;
}
/**
* @return string|null
* @Serializer\Groups({"encoded", "sign", "secure"})
* @Serializer\SerializedName("request_pass")
*/
public function getEncodedRequestPass(): ?string
{
return StrUtils::toBase64($this->requestPass);
}
public function setRequestPass(?string $requestPass): self
{
$this->requestPass = $requestPass;
return $this;
}
/**
* @return string|null
* @Serializer\Ignore
*/
public function getRequestKey(): ?string
{
return $this->requestKey;
}
/**
* @return string|null
* @Serializer\Groups({"encoded", "sign", "secure"})
* @Serializer\SerializedName("request_key")
*/
public function getEncodedRequestKey(): ?string
{
return StrUtils::toBase64($this->requestKey);
}
public function setRequestKey(?string $requestKey): self
{
$this->requestKey = $requestKey;
return $this;
}
public function getRequestReferrer(): ?array
{
return $this->requestReferrer;
}
/**
* @param null|array|string $requestReferrer
* @return $this
*/
public function setRequestReferrer($requestReferrer): self
{
if (is_string($requestReferrer)) {
$requestReferrer = [$requestReferrer];
}
$this->requestReferrer = $requestReferrer;
return $this;
}
protected function validateResponseAuth(string $responseAuth) : bool {
/* Legacy v4 response auth types */
if (in_array($responseAuth, ['hash_sha256', 'sign_sha256'])) {
return true;
}
$matches = [];
/* [1] version, [2] crypt, [3] struct */
if (!preg_match('/^v(\d{1})\_([a-f\d]{4})([A-Z])$/', $responseAuth, $matches)) {
throw new InvalidArgumentException('Invalid response auth specifier format "' . $responseAuth . '"');
}
[, $version, $crypt, $struct] = $matches;
if ($version !== '5') {
throw new InvalidArgumentException('Unsupported response auth version "' . $responseAuth . '"');
}
$cryptId = \hexdec($crypt);
$cryptInst = CryptFactory::createFromId($cryptId);
$structInst = StructFactory::create($struct);
return ($cryptInst instanceof AbstractSymmetricCrypt) && ($structInst instanceof AbstractStruct);
}
public function getResponseAuth(): string
{
return $this->responseAuth ?? self::DEFAULT_RESPONSE_AUTH;
}
public function setResponseAuth(string $responseAuth): self
{
if ($responseAuth !== $this->responseAuth) {
$this->validateResponseAuth($responseAuth);
$this->responseAuth = $responseAuth;
$this->generateResponseKey();
}
return $this;
}
/**
* @return string|null
* @Serializer\Ignore
*/
public function getResponseKey(): ?string
{
return $this->responseKey;
}
/**
* @return string|null
* @Serializer\Groups({"secure"})
* @Serializer\SerializedName("response_key")
*/
public function getEncodedResponseKey(): ?string
{
return StrUtils::toBase64($this->responseKey);
}
/**
* @return string|null
* @Serializer\Groups({"sign"})
* @Serializer\SerializedName("response_key")
*/
public function getPublicResponseKey(): ?string
{
if ($this->hasAsymmetricResponseKey()) {
return AsymmOpenSSL::getPublicKeyPem($this->responseKey);
}
return null;
}
public function setResponseKey(?string $responseKey): self
{
$this->responseKey = $responseKey;
return $this;
}
public function getExternalSubIdUrl(): ?string
{
return $this->externalSubIdUrl;
}
public function setExternalSubIdUrl(?string $externalSubIdUrl): self
{
if (empty(trim($externalSubIdUrl))) {
$this->externalSubIdUrl = null;
} else {
$this->externalSubIdUrl = $externalSubIdUrl;
}
return $this;
}
public function getResultSource(): ?string
{
return $this->resultSource;
}
public function setResultSource(?string $resultSource): self
{
$this->resultSource = $resultSource;
return $this;
}
/**
* @return array
* @throws \Exception
* @Serializer\Groups({"general"})
*/
public function getTrafficOptions(): array
{
return (array) HelperFunctions::setStdDefaults((object) $this->trafficOptions, self::TRAFFIC_OPTIONS_DEFAULTS);
}
public function setTrafficOptions(?object $trafficOptions): self
{
$this->trafficOptions = HelperFunctions::setStdDefaults((object) $trafficOptions, $this->getTrafficOptions());
return $this;
}
/**
* @return Account|null
* @Serializer\Ignore()
*/
public function getAccount(): ?Account
{
return $this->account;
}
public function setAccount(?Account $account): self
{
$this->account = $account;
return $this;
}
/**
* @return int|null
* @Serializer\Groups({"general"})
*/
public function getAccountId(): ?int
{
return ($this->account === null) ? null : $this->account->getId();
}
/**
* @return Collection<int, GwocDomain>
* @Serializer\Ignore()
*/
public function getGwocDomains(): Collection
{
return $this->gwocDomains;
}
public function addGwocDomain(GwocDomain $gwocDomain): self
{
if (!$this->gwocDomains->contains($gwocDomain)) {
$this->gwocDomains[] = $gwocDomain;
$gwocDomain->setZone($this);
$gwocDomain->setAccount($this->getAccount());
}
return $this;
}
public function removeGwocDomain(GwocDomain $gwocDomain): self
{
if ($this->gwocDomains->removeElement($gwocDomain)) {
// set the owning side to null (unless already changed)
if ($gwocDomain->getAccount() === $this->getAccount()) {
$gwocDomain->setAccount(null);
}
if ($gwocDomain->getZone() === $this) {
$gwocDomain->setZone(null);
}
}
return $this;
}
public function hasAsymmetricResponseKey(): bool
{
return StrUtils::startsWith($this->responseAuth ?? '', 'sign_');
}
/**
* @return string|null
* @Serializer\Groups({"general"})
*/
public function getEffExternalSubIdUrl(): ?string
{
return $this->externalSubIdUrl ?? $this->account->getExternalSubIdUrl();
}
public function fetchLiveData(int $userId): void
{
if (empty($this->id)) {
throw new RuntimeException('Cannot fetch live data for non-existent zone');
}
$this->liveData = TrafficRequest::encodeLive(Result::current($this->frontInvoker()->zoneGetById($userId, $this->account->getId(), $this->getId()), 'zone'));
}
public function checkPrivileges(int $userId, int $accountId, $action = null): void
{
$this->frontInvoker()->zonePrivilegeCheck($userId, $accountId, $this->getId());
}
public function generateResponseKey(): void
{
if ($this->hasAsymmetricResponseKey()) {
$this->responseKey = AsymmOpenSSL::createEcPrivateKey();
} else {
$this->responseKey = random_bytes(32);
}
}
public function fetchLtam(): void
{
if (empty($this->id)) {
throw new RuntimeException('Cannot fetch traffic data for non-existent zone');
}
$data = $this->frontInvoker()->query('SELECT LOWER(HEX(id)) AS hash, value AS seen FROM adscore.kv_records WHERE zone_id = :zone_id', ['zone_id' => $this->id]);
$this->ltam = array_combine(array_column($data, 'hash'), array_map('intval', array_column($data, 'seen')));
}
/**
* Gather traffic data from Clickhouse nodes
* @return void
* @throws RuntimeException
*/
public function fetchTrafficData(): void
{
if (empty($this->id)) {
throw new RuntimeException('Cannot fetch traffic data for non-existent zone');
}
$rawData = Gearboy::query([
'has_sub_id' => [
'query' => 'SELECT count(DISTINCT sub_id) AS count FROM (SELECT * FROM traffic WHERE (zone_id = :zone_id) AND isNotNull(sub_id) AND (sub_id != \'\') LIMIT 2)',
'args' => ['zone_id' => $this->id],
'pool' => 'traffic_local_copy',
'type' => 'value'
],
'has_traffic' => [
'query' => 'SELECT count(*) AS count FROM (SELECT * FROM traffic WHERE (zone_id = :zone_id) LIMIT 2)',
'args' => ['zone_id' => $this->id],
'pool' => 'traffic_local_copy',
'type' => 'value'
]
]);
$this->hasSubId = (bool)$rawData['has_sub_id'] ?? false;
$this->hasTraffic = (bool)$rawData['has_traffic'] ?? false;
}
/**
* @return array|null
* @Serializer\Ignore()
*/
public function getLtam(): array
{
if ($this->ltam === null) {
$this->fetchLtam();
}
return $this->ltam;
}
/**
* @return bool|null
* @Serializer\SerializedName("has_sub_id")
* @Serializer\Groups({"read", "general"})
*/
public function hasSubId(): ?bool
{
return $this->hasSubId;
}
/**
* @return bool|null
* @Serializer\SerializedName("has_traffic")
* @Serializer\Groups({"read", "general"})
*/
public function hasTraffic(): ?bool
{
return $this->hasTraffic;
}
/**
* @return array|null
* @Serializer\Groups({"read", "secure"})
*/
public function getApikey(): ?string
{
return $this->liveData['apikey'] ?? $this->generateApikey();
}
/**
* @return array|null
* @Serializer\Groups({"read"})
*/
public function isApikeyActive(): ?bool
{
return $this->liveData['apikey_active'] ?? null;
}
/**
* @return array|null
*/
public function getCustomFields(): array
{
if (empty($this->customFields)) {
return [];
}
return explode(',', $this->customFields);
}
/**
* @param array|null $customFields
* @return Zone
*/
public function setCustomFields(?array $customFields): Zone
{
$customFields = implode(',', array_unique((array) $customFields));
$this->customFields = empty($customFields) ? null : $customFields;
return $this;
}
}