<?php
/**
 * Copyright 2019 Cloud Creativity Limited
 *
 * Licensed 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.
 */

namespace CloudCreativity\LaravelJsonApi\Document;

use CloudCreativity\LaravelJsonApi\Contracts\Object\ResourceObjectInterface;
use CloudCreativity\LaravelJsonApi\Object\ResourceObject as LegacyResourceObject;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;

class ResourceObject implements Arrayable, \IteratorAggregate, \JsonSerializable, \ArrayAccess
{

    /**
     * @var string
     */
    private $type;

    /**
     * @var string|null
     */
    private $id;

    /**
     * @var array
     */
    private $attributes;

    /**
     * @var array
     */
    private $relationships;

    /**
     * @var array
     */
    private $meta;

    /**
     * @var array
     */
    private $links;

    /**
     * @var Collection
     */
    private $fieldValues;

    /**
     * @var Collection
     */
    private $fieldNames;

    /**
     * Create a resource object from the data member of a JSON document.
     *
     * @param array $data
     * @return ResourceObject
     */
    public static function create(array $data): self
    {
        if (!isset($data['type'])) {
            throw new \InvalidArgumentException('Expecting a resource type.');
        }

        return new self(
            $data['type'],
            $data['id'] ?? null,
            $data['attributes'] ?? [],
            $data['relationships'] ?? [],
            $data['meta'] ?? [],
            $data['links'] ?? []
        );
    }

    /**
     * ResourceObject constructor.
     *
     * @param string $type
     * @param string|null $id
     * @param array $attributes
     * @param array $relationships
     * @param array $meta
     * @param array $links
     */
    public function __construct(
        string $type,
        ?string $id,
        array $attributes,
        array $relationships = [],
        array $meta = [],
        array $links = []
    ) {
        if (empty($type)) {
            throw new \InvalidArgumentException('Expecting a non-empty string.');
        }

        $this->type = $type;
        $this->id = $id ?: null;
        $this->attributes = $attributes;
        $this->relationships = $relationships;
        $this->meta = $meta;
        $this->links = $links;
        $this->normalize();
    }

    /**
     * @return void
     */
    public function __clone()
    {
        $this->fieldNames = clone $this->fieldNames;
        $this->fieldValues = clone $this->fieldValues;
    }

    /**
     * @param string $field
     * @return mixed
     */
    public function __get($field)
    {
        return $this->offsetGet($field);
    }

    /**
     * @param $field
     * @param $value
     */
    public function __set($field, $value)
    {
        throw new \LogicException('Resource object is immutable.');
    }

    /**
     * @param $field
     * @return bool
     */
    public function __isset($field)
    {
        return $this->offsetExists($field);
    }

    /**
     * @param $field
     */
    public function __unset($field)
    {
        throw new \LogicException('Resource object is immutable.');
    }

    /**
     * @inheritDoc
     */
    public function offsetExists($offset)
    {
        return $this->fieldValues->offsetExists($offset);
    }

    /**
     * @inheritDoc
     */
    public function offsetGet($offset)
    {
        return $this->fieldValues->offsetGet($offset);
    }

    /**
     * @inheritDoc
     */
    public function offsetSet($offset, $value)
    {
        throw new \LogicException('Resource object is immutable.');
    }

    /**
     * @inheritDoc
     */
    public function offsetUnset($offset)
    {
        throw new \LogicException('Resource object is immutable.');
    }

    /**
     * @return string
     */
    public function getType(): string
    {
        return $this->type;
    }

    /**
     * Return a new instance with the specified type.
     *
     * @param string $type
     * @return ResourceObject
     */
    public function withType(string $type): self
    {
        if (empty($type)) {
            throw new \InvalidArgumentException('Expecting a non-empty string.');
        }

        $copy = clone $this;
        $copy->type = $type;
        $copy->normalize();

        return $copy;
    }

    /**
     * @return string|null
     */
    public function getId(): ?string
    {
        return $this->id;
    }

    /**
     * Return a new instance with the specified id.
     *
     * @param string|null $id
     * @return ResourceObject
     */
    public function withId(?string $id): self
    {
        $copy = clone $this;
        $copy->id = $id ?: null;
        $copy->normalize();

        return $copy;
    }

    /**
     * Return a new instance without an id.
     *
     * @return ResourceObject
     */
    public function withoutId(): self
    {
        return $this->withId(null);
    }

    /**
     * @return Collection
     */
    public function getAttributes(): Collection
    {
        return collect($this->attributes);
    }

    /**
     * Is the field an attribute?
     *
     * @param string $field
     * @return bool
     */
    public function isAttribute(string $field): bool
    {
        return array_key_exists($field, $this->attributes);
    }

    /**
     * Return a new instance with the provided attributes.
     *
     * @param array|Collection $attributes
     * @return ResourceObject
     */
    public function withAttributes($attributes): self
    {
        $copy = clone $this;
        $copy->attributes = collect($attributes)->all();
        $copy->normalize();

        return $copy;
    }

    /**
     * Return a new instance without attributes.
     *
     * @return ResourceObject
     */
    public function withoutAttributes(): self
    {
        return $this->withAttributes([]);
    }

    /**
     * @return Collection
     */
    public function getRelationships(): Collection
    {
        return collect($this->relationships);
    }

    /**
     * Is the field a relationship?
     *
     * @param string $field
     * @return bool
     */
    public function isRelationship(string $field): bool
    {
        return array_key_exists($field, $this->relationships);
    }

    /**
     * Return a new instance with the provided relationships.
     *
     * @param array|Collection $relationships
     * @return ResourceObject
     */
    public function withRelationships($relationships): self
    {
        $copy = clone $this;
        $copy->relationships = collect($relationships)->all();
        $copy->normalize();

        return $copy;
    }

    /**
     * Return a new instance without relationships.
     *
     * @return ResourceObject
     */
    public function withoutRelationships(): self
    {
        return $this->withRelationships([]);
    }

    /**
     * Get the data value of all relationships.
     *
     * @return Collection
     */
    public function getRelations(): Collection
    {
        return $this->getRelationships()->filter(function (array $relation) {
            return array_key_exists('data', $relation);
        })->map(function (array $relation) {
            return $relation['data'];
        });
    }

    /**
     * @return Collection
     */
    public function getMeta(): Collection
    {
        return collect($this->meta);
    }

    /**
     * Return a new instance with the provided meta.
     *
     * @param array|Collection $meta
     * @return ResourceObject
     */
    public function withMeta($meta): self
    {
        $copy = clone $this;
        $copy->meta = collect($meta)->all();

        return $copy;
    }

    /**
     * Return a new instance without meta.
     *
     * @return ResourceObject
     */
    public function withoutMeta(): self
    {
        return $this->withMeta([]);
    }

    /**
     * @return Collection
     */
    public function getLinks(): Collection
    {
        return collect($this->links);
    }

    /**
     * Return a new instance with the provided links.
     *
     * @param $links
     * @return ResourceObject
     */
    public function withLinks($links): self
    {
        $copy = clone $this;
        $copy->links = collect($links)->all();

        return $copy;
    }

    /**
     * Return a new instance without links.
     *
     * @return ResourceObject
     */
    public function withoutLinks(): self
    {
        return $this->withLinks([]);
    }

    /**
     * Get all the field names.
     *
     * @return Collection
     */
    public function fields(): Collection
    {
        return $this->fieldNames->values();
    }

    /**
     * Get a field value.
     *
     * @param string $field
     * @param mixed $default
     * @return mixed
     */
    public function get(string $field, $default = null)
    {
        return Arr::get($this->all(), $field, $default);
    }

    /**
     * Do the fields exist?
     *
     * @param string ...$fields
     * @return bool
     */
    public function has(string ...$fields): bool
    {
        return $this->fieldNames->has($fields);
    }

    /**
     * Return a new instance with the supplied attribute/relationship fields removed.
     *
     * @param string ...$fields
     * @return ResourceObject
     */
    public function forget(string ...$fields): self
    {
        $copy = clone $this;
        $copy->attributes = $this->getAttributes()->forget($fields)->all();
        $copy->relationships = $this->getRelationships()->forget($fields)->all();
        $copy->normalize();

        return $copy;
    }

    /**
     * Return a new instance that only has the specified attribute/relationship fields.
     *
     * @param string ...$fields
     * @return ResourceObject
     */
    public function only(string ...$fields): self
    {
        $forget = $this->fields()->reject(function ($value) use ($fields) {
            return in_array($value, $fields, true);
        });

        return $this->forget(...$forget);
    }

    /**
     * Return a new instance with a new attribute/relationship field value.
     *
     * The field must exist, otherwise it cannot be determined whether to replace
     * either an attribute or a relationship.
     *
     * If the field is a relationship, the `data` member of that relationship will
     * be replaced.
     *
     * @param string $field
     * @param $value
     * @return ResourceObject
     * @throws \OutOfBoundsException if the field does not exist.
     */
    public function replace(string $field, $value): self
    {
        if ('type' === $field) {
            return $this->putIdentifier($value, $this->id);
        }

        if ('id' === $field) {
            return $this->putIdentifier($this->type, $value);
        }

        if ($this->isAttribute($field)) {
            return $this->putAttr($field, $value);
        }

        if ($this->isRelationship($field)) {
            return $this->putRelation($field, $value);
        }

        throw new \OutOfBoundsException("Field {$field} is not an attribute or relationship.");
    }

    /**
     * Convert a validation key to a JSON pointer.
     *
     * @param string $key
     * @param string $prefix
     * @return string
     */
    public function pointer(string $key, string $prefix = ''): string
    {
        $prefix = rtrim($prefix, '/');

        if ('type' === $key) {
            return $prefix . '/type';
        }

        if ('id' === $key) {
            return $prefix . '/id';
        }

        $parts = collect(explode('.', $key));
        $field = $parts->first();

        if ($this->isAttribute($field)) {
            return $prefix . '/attributes/' . $parts->implode('/');
        }

        if ($this->isRelationship($field)) {
            $name = 1 < $parts->count() ? $field . '/' . $parts->put(0, 'data')->implode('/') : $field;
            return $prefix . "/relationships/{$name}";
        }

        return $prefix ? $prefix : '/';
    }

    /**
     * Convert a validation key to a JSON pointer for a relationship object within the resource.
     *
     * @param string $key
     * @param string $default
     * @return string
     */
    public function pointerForRelationship(string $key, string $default = '/'): string
    {
        $field = collect(explode('.', $key))->first();

        if (!$this->isRelationship($field)) {
            throw new \InvalidArgumentException("Field {$field} is not a relationship.");
        }

        $pointer = $this->pointer($key);

        return Str::after($pointer, "relationships/{$field}") ?: $default;
    }

    /**
     * Get the values of all fields.
     *
     * @return array
     */
    public function all(): array
    {
        return $this->fieldValues->all();
    }

    /**
     * @inheritDoc
     */
    public function getIterator()
    {
        return $this->fieldValues->getIterator();
    }

    /**
     * @inheritDoc
     */
    public function toArray()
    {
        return collect([
            'type' => $this->type,
            'id' => $this->id,
            'attributes' => $this->attributes,
            'relationships' => $this->relationships,
            'links' => $this->links,
            'meta' => $this->meta,
        ])->filter()->all();
    }

    /**
     * @inheritDoc
     */
    public function jsonSerialize()
    {
        return $this->toArray();
    }

    /**
     * Convert the object to a legacy resource object.
     *
     * @return ResourceObjectInterface
     * @deprecated 2.0.0
     */
    public function toObject(): ResourceObjectInterface
    {
        $json = json_encode($this);

        return new LegacyResourceObject(json_decode($json, false));
    }

    /**
     * @return Collection
     */
    private function fieldValues(): Collection
    {
        $fields = collect($this->attributes)->merge($this->getRelations())->merge([
            'type' => $this->type,
            'id' => $this->id,
        ]);

        /** Can use `sortKeys()` on collection when Laravel >= 5.6 */
        $all = $fields->all();
        ksort($all);

        return collect($all);
    }

    /**
     * @return Collection
     */
    private function fieldNames(): Collection
    {
        $fields = collect(['type', 'id'])
            ->merge(collect($this->attributes)->keys())
            ->merge(collect($this->relationships)->keys())
            ->sort()
            ->values();

        return $fields->combine($fields);
    }

    /**
     * @return void
     */
    private function normalize(): void
    {
        if (collect($this->attributes)->intersectByKeys($this->relationships)->isNotEmpty()) {
            throw new \LogicException('Attributes and relationships cannot have the same field names.');
        }

        $this->fieldValues = $this->fieldValues();
        $this->fieldNames = $this->fieldNames();
    }

    /**
     * @param string $type
     * @param string|null $id
     * @return ResourceObject
     */
    private function putIdentifier(string $type, ?string $id): self
    {
        $copy = clone $this;
        $copy->type = $type;
        $copy->id = $id;
        $copy->normalize();

        return $copy;
    }

    /**
     * @param string $field
     * @param $value
     * @return ResourceObject
     */
    private function putAttr(string $field, $value): self
    {
        $copy = clone $this;
        $copy->attributes[$field] = $value;
        $copy->normalize();

        return $copy;
    }

    /**
     * @param string $field
     * @param array|null $value
     * @return ResourceObject
     */
    private function putRelation(string $field, ?array $value): self
    {
        $copy = clone $this;
        $copy->relationships[$field] = $copy->relationships[$field] ?? [];
        $copy->relationships[$field]['data'] = $value;
        $copy->normalize();

        return $copy;
    }

}


<?php

/**
 * Copyright 2019 Cloud Creativity Limited
 *
 * Licensed 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.
 */

namespace CloudCreativity\LaravelJsonApi\Object;

use CloudCreativity\LaravelJsonApi\Exceptions\RuntimeException;
use CloudCreativity\Utils\Object\StandardObject;
use CloudCreativity\Utils\Object\StandardObjectInterface;
use Neomerx\JsonApi\Contracts\Document\DocumentInterface;

/**
 * Class MetaMemberTrait
 *
 * @package CloudCreativity\LaravelJsonApi
 * @deprecated 2.0.0
 */
trait MetaMemberTrait
{

    /**
     * Get the meta member of the document.
     *
     * @return StandardObjectInterface
     * @throws RuntimeException
     *      if the meta member is present and is not an object or null.
     */
    public function getMeta()
    {
        $meta = $this->hasMeta() ? $this->get(DocumentInterface::KEYWORD_META) : new StandardObject();

        if (!is_null($meta) && !$meta instanceof StandardObjectInterface) {
            throw new RuntimeException('Data member is not an object.');
        }

        return $meta;
    }

    /**
     * @return bool
     */
    public function hasMeta()
    {
        return $this->has(DocumentInterface::KEYWORD_META);
    }

}


<?php
/**
 * Copyright 2019 Cloud Creativity Limited
 *
 * Licensed 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.
 */

namespace CloudCreativity\LaravelJsonApi\Utils;

/**
 * Trait InvokesHooks
 *
 * @package CloudCreativity\LaravelJsonApi
 */
trait InvokesHooks
{

    /**
     * Should the invoked result be returned?
     *
     * @param mixed $result
     * @return bool
     */
    abstract protected function isInvokedResult($result): bool;

    /**
     * Invoke a hook.
     *
     * @param string $hook
     * @param mixed ...$arguments
     * @return mixed|null
     */
    protected function invoke(string $hook, ...$arguments)
    {
        if (!method_exists($this, $hook)) {
            return null;
        }

        $result = $this->{$hook}(...$arguments);

        return $this->isInvokedResult($result) ? $result : null;
    }

    /**
     * Invoke multiple hooks.
     *
     * @param iterable $hooks
     * @param mixed ...$arguments
     * @return mixed|null
     */
    protected function invokeMany(iterable $hooks, ...$arguments)
    {
        foreach ($hooks as $hook) {
            $result = $this->invoke($hook, ...$arguments);

            if (!is_null($result)) {
                return $result;
            }
        }

        return null;
    }

}


<?php
/**
 * Copyright 2019 Cloud Creativity Limited
 *
 * Licensed 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.
 */

namespace CloudCreativity\LaravelJsonApi\Validation\Spec;

use CloudCreativity\LaravelJsonApi\Contracts\Store\StoreInterface;
use CloudCreativity\LaravelJsonApi\Exceptions\InvalidArgumentException;
use CloudCreativity\LaravelJsonApi\Validation\ErrorTranslator;

/**
 * Class CreateResourceValidator
 *
 * @package CloudCreativity\LaravelJsonApi
 */
class CreateResourceValidator extends AbstractValidator
{

    /**
     * The expected JSON API type.
     *
     * @var string
     */
    private $expectedType;

    /**
     * Whether client ids are supported.
     *
     * @var bool
     */
    private $clientIds;

    /**
     * CreateResourceValidator constructor.
     *
     * @param StoreInterface $store
     * @param ErrorTranslator $translator
     * @param object $document
     * @param string $expectedType
     * @param bool $clientIds
     *      whether client ids are supported.
     */
    public function __construct(
        StoreInterface $store,
        ErrorTranslator $translator,
        $document,
        string $expectedType,
        bool $clientIds = false
    ) {
        if (empty($expectedType)) {
            throw new InvalidArgumentException('Expecting type to be a non-empty string.');
        }

        parent::__construct($store, $translator, $document);
        $this->expectedType = $expectedType;
        $this->clientIds = $clientIds;
    }

    /**
     * @inheritDoc
     */
    protected function validate(): bool
    {
        /** If the data is not valid, we cannot validate the resource. */
        if (!$this->validateData()) {
            return false;
        }

        return $this->validateResource();
    }

    /**
     * Validate that the top-level `data` member is acceptable.
     *
     * @return bool
     */
    protected function validateData(): bool
    {
        if (!property_exists($this->document, 'data')) {
            $this->memberRequired('/', 'data');
            return false;
        }

        $data = $this->document->data;

        if (!is_object($data)) {
            $this->memberNotObject('/', 'data');
            return false;
        }

        return true;
    }

    /**
     * Validate the resource object.
     *
     * @return bool
     */
    protected function validateResource(): bool
    {
        $identifier = $this->validateTypeAndId();
        $attributes = $this->validateAttributes();
        $relationships = $this->validateRelationships();

        if ($attributes && $relationships) {
            return $this->validateAllFields() && $identifier;
        }

        return $identifier && $attributes && $relationships;
    }

    /**
     * Validate the resource type and id.
     *
     * @return bool
     */
    protected function validateTypeAndId(): bool
    {
        if (!($this->validateType() && $this->validateId())) {
            return false;
        }

        $type = $this->dataGet('type');
        $id = $this->dataGet('id');

        if ($id && !$this->isNotFound($type, $id)) {
            $this->resourceExists($type, $id);
            return false;
        }

        return true;
    }

    /**
     * Validate the resource type.
     *
     * @return bool
     */
    protected function validateType(): bool
    {
        if (!$this->dataHas('type')) {
            $this->memberRequired('/data', 'type');
            return false;
        }

        $value = $this->dataGet('type');

        if (!$this->validateTypeMember($value, '/data')) {
            return false;
        }

        if ($this->expectedType !== $value) {
            $this->resourceTypeNotSupported($value);
            return false;
        }

        return true;
    }

    /**
     * Validate the resource id.
     *
     * @return bool
     */
    protected function validateId(): bool
    {
        if (!$this->dataHas('id')) {
            return true;
        }

        $valid = $this->validateIdMember($this->dataGet('id'), '/data');

        if (!$this->supportsClientIds()) {
            $valid = false;
            $this->resourceDoesNotSupportClientIds($this->expectedType);
        }

        return $valid;
    }

    /**
     * Validate the resource attributes.
     *
     * @return bool
     */
    protected function validateAttributes(): bool
    {
        if (!$this->dataHas('attributes')) {
            return true;
        }

        $attrs = $this->dataGet('attributes');

        if (!is_object($attrs)) {
            $this->memberNotObject('/data', 'attributes');
            return false;
        }

        $disallowed = collect(['type', 'id'])->filter(function ($field) use ($attrs) {
            return property_exists($attrs, $field);
        });

        $this->memberFieldsNotAllowed('/data', 'attributes', $disallowed);

        return $disallowed->isEmpty();
    }

    /**
     * Validate the resource relationships.
     *
     * @return bool
     */
    protected function validateRelationships(): bool
    {
        if (!$this->dataHas('relationships')) {
            return true;
        }

        $relationships = $this->dataGet('relationships');

        if (!is_object($relationships)) {
            $this->memberNotObject('/data', 'relationships');
            return false;
        }

        $disallowed = collect(['type', 'id'])->filter(function ($field) use ($relationships) {
            return property_exists($relationships, $field);
        });

        $valid = $disallowed->isEmpty();
        $this->memberFieldsNotAllowed('/data', 'relationships', $disallowed);

        foreach ($relationships as $field => $relation) {
            if (!$this->validateRelationship($relation, $field)) {
                $valid = false;
            }
        }

        return $valid;
    }

    /**
     * Validate the resource's attributes and relationships collectively.
     *
     * @return bool
     */
    protected function validateAllFields(): bool
    {
        $duplicates = collect(
            (array) $this->dataGet('attributes', [])
        )->intersectByKeys(
            (array) $this->dataGet('relationships', [])
        )->keys();

        $this->resourceFieldsExistInAttributesAndRelationships($duplicates);

        return $duplicates->isEmpty();
    }

    /**
     * Are client ids supported?
     *
     * @return bool
     */
    protected function supportsClientIds(): bool
    {
        return $this->clientIds;
    }

}


<?php
/**
 * Copyright 2019 Cloud Creativity Limited
 *
 * Licensed 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.
 */

namespace CloudCreativity\LaravelJsonApi\Auth;

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests as IlluminateAuthorizesRequests;
use Illuminate\Support\Facades\Auth;

/**
 * Trait AuthorizesRequests
 *
 * @package CloudCreativity\LaravelJsonApi
 */
trait AuthorizesRequests
{

    use IlluminateAuthorizesRequests;

    /**
     * The guards to use to authenticate a user.
     *
     * By default we use the `api` guard. Change this to either different
     * named guards, or an empty array to use the default guard.
     *
     * @var array
     */
    protected $guards = ['api'];

    /**
     * @param $ability
     * @param mixed ...$arguments
     * @throws AuthenticationException
     * @throws AuthorizationException
     */
    protected function can($ability, ...$arguments)
    {
        $this->authenticate();
        $this->authorize($ability, $arguments);
    }

    /**
     * Determine if the user is logged in.
     *
     * @return void
     * @throws AuthenticationException
     */
    protected function authenticate()
    {
        if (empty($this->guards) && Auth::check()) {
            return;
        }

        foreach ($this->guards as $guard) {
            if (Auth::guard($guard)->check()) {
                Auth::shouldUse($guard);
                return;
            }
        }

        throw new AuthenticationException('Unauthenticated.', $this->guards);
    }
}


<?php
/**
 * Copyright 2019 Cloud Creativity Limited
 *
 * Licensed 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.
 */

namespace CloudCreativity\LaravelJsonApi\Client;

use CloudCreativity\LaravelJsonApi\Contracts\Encoder\SerializerInterface;
use Illuminate\Support\Collection;
use Neomerx\JsonApi\Contracts\Encoder\Parameters\EncodingParametersInterface;
use Neomerx\JsonApi\Contracts\Http\HttpFactoryInterface;

/**
 * Class ClientSerializer
 *
 * @package CloudCreativity\LaravelJsonApi
 */
class ClientSerializer
{

    /**
     * @var SerializerInterface
     */
    protected $serializer;

    /**
     * @var HttpFactoryInterface
     */
    protected $factory;

    /**
     * @var bool
     */
    private $links;

    /**
     * @var array|null
     */
    private $includePaths;

    /**
     * @var bool
     */
    private $compoundDocuments;

    /**
     * @var array|null
     */
    private $fieldsets;

    /**
     * ClientSerializer constructor.
     *
     * @param SerializerInterface $serializer
     * @param HttpFactoryInterface $factory
     */
    public function __construct(SerializerInterface $serializer, HttpFactoryInterface $factory)
    {
        $this->serializer = $serializer;
        $this->factory = $factory;
        $this->links = false;
        $this->includePaths = null;
        $this->fieldsets = null;
        $this->compoundDocuments = false;
    }

    /**
     * @param bool $links
     * @return ClientSerializer
     */
    public function withLinks($links = true)
    {
        $copy = clone $this;
        $copy->links = $links;

        return $copy;
    }

    /**
     * @param string ...$paths
     * @return ClientSerializer
     */
    public function withIncludePaths(...$paths)
    {
        $copy = clone $this;
        $copy->includePaths = $paths ?: null;

        return $copy;
    }

    /**
     * @param bool $bool
     * @return ClientSerializer
     */
    public function withCompoundDocuments($bool = true)
    {
        $copy = clone $this;
        $copy->compoundDocuments = $bool;

        return $copy;
    }

    /**
     * @param string $resourceType
     * @param string|string[] $fields
     * @return ClientSerializer
     */
    public function withFieldsets($resourceType, $fields)
    {
        $fieldsets = $this->fieldsets ?: [];

        if ($fields) {
            $fieldsets[$resourceType] = (array) $fields;
        } else {
            unset($fieldsets[$resourceType]);
        }

        $copy = clone $this;
        $copy->fieldsets = $fieldsets ?: null;

        return $copy;
    }

    /**
     * Serialize a domain record.
     *
     * @param $record
     * @param mixed|null $meta
     * @param mixed|null $links
     * @return array
     */
    public function serialize($record, $meta = null, array $links = [])
    {
        $serializer = clone $this->serializer;
        $serializer->withMeta($meta)->withLinks($links);
        $serialized = $serializer->serializeData($record, $this->createEncodingParameters());
        $resourceLinks = null;

        if (empty($serialized['data']['id'])) {
            unset($serialized['data']['id']);
            $resourceLinks = false; // links will not be valid so strip them out.
        }

        $resource = $this->parsePrimaryResource($serialized['data'], $resourceLinks);
        $document = ['data' => $resource];

        if (isset($serialized['included']) && $this->doesSerializeCompoundDocuments()) {
            $document['included'] = $this->parseIncludedResources($serialized['included']);
        }

        return $document;
    }

    /**
     * Serialize related record(s).
     *
     * @param object|iterable|array|null $related
     * @param mixed|null $meta
     * @param array $links
     * @return array
     */
    public function serializeRelated($related, $meta = null, array $links = [])
    {
        $serializer = clone $this->serializer;
        $serializer->withMeta($meta)->withLinks($links);

        return $serializer->serializeIdentifiers($related);
    }

    /**
     * @param array $resource
     * @param bool|null $links
     * @return array
     */
    protected function parsePrimaryResource(array $resource, $links = null)
    {
        return $this->parseResource($resource, true, $links);
    }

    /**
     * @param array $resources
     * @return Collection
     */
    protected function parseIncludedResources(array $resources)
    {
        return $this->parseResources($resources, false);
    }

    /**
     * @param array $resources
     * @param bool $primary
     * @return Collection
     */
    protected function parseResources(array $resources, $primary = false)
    {
        return collect($resources)->map(function (array $resource) use ($primary) {
            return $this->parseResource($resource, $primary);
        });
    }

    /**
     * @param array $resource
     * @param bool $primary
     * @param bool|null $links
     * @return array
     */
    protected function parseResource(array $resource, $primary = false, $links = null)
    {
        if (false === $links || $this->doesRemoveLinks()) {
            unset($resource['links']);
        }

        $relationships = isset($resource['relationships']) ?
            $this->parseRelationships($resource['relationships'], $primary, $links) : [];

        if ($relationships) {
            $resource['relationships'] = $relationships;
        } else {
            unset($resource['relationships']);
        }

        return $resource;
    }

    /**
     * @param array $relationships
     * @param bool $primary
     * @param bool|null $links
     * @return array
     */
    protected function parseRelationships(array $relationships, $primary = false, $links = null)
    {
        return collect($relationships)->reject(function (array $relation) use ($primary) {
            return $primary && !array_key_exists('data', $relation);
        })->map(function (array $relation) use ($primary, $links) {
            return $this->parseRelationship($relation, $primary, $links);
        })->filter()->all();
    }

    /**
     * @param array $relationship
     * @param bool $primary
     * @param null $links
     * @return array|null
     */
    protected function parseRelationship(array $relationship, $primary = false, $links = null)
    {
        if (false === $links || $this->doesRemoveLinks()) {
            unset($relationship['links']);
        }

        return $relationship ?: null;
    }

    /**
     * @return bool
     */
    protected function doesSerializeCompoundDocuments()
    {
        return $this->compoundDocuments;
    }

    /**
     * @return bool
     */
    protected function doesRemoveLinks()
    {
        return !$this->links;
    }

    /**
     * @return EncodingParametersInterface
     */
    protected function createEncodingParameters()
    {
        return $this->factory->createQueryParameters(
            $this->includePaths,
            $this->fieldsets
        );
    }
}


<?php

namespace ether\seo\fields;

use craft\base\ElementInterface;
use craft\base\Field;
use craft\base\PreviewableFieldInterface;
use craft\elements\Category;
use craft\elements\Entry;
use craft\helpers\Json;
use craft\models\Section;
use ether\seo\models\data\SeoData;
use ether\seo\Seo;
use ether\seo\web\assets\SeoFieldAsset;
use ether\seo\web\assets\SeoFieldSettingsAsset;
use yii\base\InvalidConfigException;
use yii\db\Schema;

class SeoField extends Field implements PreviewableFieldInterface
{

	// Props
	// =========================================================================

	// Static
	// -------------------------------------------------------------------------

	public static $defaultFieldSettings = [
		'titleSuffix' => null,
		'suffixAsPrefix' => false,

		'title' => [],
		'description' => '',

		'socialImage' => null,
		'hideSocial' => false,
		'robots' => [],
	];

	// Instance
	// -------------------------------------------------------------------------

	/**
	 * @var string
	 * @deprecated
	 */
	public $titleSuffix;

	/**
	 * @var bool
	 * @deprecated
	 */
	public $suffixAsPrefix;

	/** @var array */
	public $title;

	/** @var string */
	public $description;

	/** @var mixed */
	public $socialImage;

	/** @var boolean */
	public $hideSocial;

	/** @var array */
	public $robots;

	// Public Functions
	// =========================================================================

	// Static
	// -------------------------------------------------------------------------

	public static function displayName (): string
	{
		return \Craft::t('seo', 'SEO');
	}

	public static function hasContentColumn (): bool
	{
		return true;
	}

	// Instance
	// -------------------------------------------------------------------------

	public function getContentColumnType (): string
	{
		return Schema::TYPE_TEXT;
	}

	public function normalizeValue ($value, ElementInterface $element = null)
	{
		if ($value instanceof SeoData)
			return $value;

		if (is_string($value))
			$value = Json::decodeIfJson($value);

		return new SeoData($this, $element, $value ?? []);
	}

	/**
	 * @param SeoData               $value
	 * @param ElementInterface|null $element
	 *
	 * @return string
	 * @throws InvalidConfigException
	 * @throws \Twig_Error_Loader
	 * @throws \yii\base\Exception
	 */
	public function getInputHtml ($value, ElementInterface $element = null): string
	{
		if (!$element) return '';

		// Variables
		// ---------------------------------------------------------------------
		$craft = \Craft::$app;
		$namespaceId = $craft->view->namespaceInputId($this->id);

		$settings = $this->getSettings();
		$settingsGlobal = Seo::$i->getSettings();

		$hasPreview = false;
		$section = null;
		$isEntry = false;
		$isCalendar = false;
		$isHome = false;
		$isNew = $element->getId() === null;
		$isSingle = false;
		$previewAction = null;

		switch (get_class($element)) {
			case 'craft\\elements\\Entry':
				/** @var Entry $element */
				try {
					$isEntry = true;
					$section = $element->getSection();
				} catch (InvalidConfigException $e) {}
				$previewAction = $craft->getSecurity()->hashData(
					'entries/preview-entry'
				);
				break;
			case 'craft\\elements\\Category':
				$previewAction = $craft->getSecurity()->hashData(
					'categories/preview-category'
				);
				break;
			case 'craft\\commerce\\elements\\Product':
				$previewAction = $craft->getSecurity()->hashData(
					'commerce/products-preview/preview-product'
				);
				break;
			case 'Solspace\\Calendar\\Elements\\Event':
				/** @var $element \Solspace\Calendar\Elements\Event */
				$isCalendar = true;
				$previewAction = $craft->getSecurity()->hashData(
					'calendar/events/preview'
				);
				$hasPreview = \Solspace\Calendar\Calendar::getInstance()->calendars->isEventTemplateValid(
					$element->getCalendar(),
					$element->siteId
				);
				break;
			default:
				/** @var ElementInterface $element */
		}

		if ($section) {
			$hasPreview = $craft->sections->isSectionTemplateValid(
				$section,
				$element->siteId
			) && $previewAction !== null;

			$isSingle = $section->type === Section::TYPE_SINGLE;
		}

		// URL & Title Suffix
		// ---------------------------------------------------------------------

		$titleTemplate = $settings['title'] ?? $settingsGlobal['title'];

		$url = $element->getUrl();

		if ($hasPreview && $isEntry && !$isHome && !$isSingle)
			$url = substr($url, 0, strrpos( $url, '/')) . '/';

		if ($element->slug)
			$url = str_replace($element->slug, '', $url);

		// Social URL
		// ---------------------------------------------------------------------

		$socialPreviewUrl = null;

		if ($craft->sites->currentSite->baseUrl) {
			preg_match(
				"((http?s?:\/\/)?(www.)?(.*)\/)",
				\Craft::parseEnv($craft->sites->currentSite->baseUrl),
				$socialPreviewUrl
			);
			$socialPreviewUrl = $socialPreviewUrl[3];
		}

		// Advanced
		// ---------------------------------------------------------------------

		$defaultRobots = array_key_exists('robots', $settings)
			? $settings['robots']
			: [];

		// Render
		// ---------------------------------------------------------------------

		$hideSocial = array_key_exists('hideSocial', $settings)
			? $settings['hideSocial']
			: false;

		$renderData = [
			'elementType' => get_class($element),
			'elementId' => $element->id,
			'siteId' => $element->siteId,
			'seoHandle' => $this->handle,
		];

		if ($element instanceof Category)
			$renderData['groupId'] = $element->groupId;
		elseif ($isCalendar)
			$renderData['calendarId'] = $element->calendarId;
		else
			$renderData['typeId'] = $element->typeId;

		$seoOptions = Json::encode(compact(
			'hasPreview',
			'previewAction',
			'isNew',
			'renderData'
		));

		$craft->view->registerAssetBundle(SeoFieldAsset::class);
		$craft->view->registerJs(
			"new SeoField('{$namespaceId}', {$seoOptions})"
		);

		return $craft->view->renderTemplate(
			'seo/_seo/fieldtype',
			[
				'id' => $this->id,
				'name' => $this->handle,
				'value' => $value,
				'titleTemplate' => $titleTemplate,
				'hasPreview' => $hasPreview,
				'url' => $url,
				'isPro' => true,

				'isNew' => $isNew,
				'isHome' => $isHome,
				'isSingle' => $isSingle,

				'socialPreviewUrl' => $socialPreviewUrl,
				'hideSocial' => $hideSocial,

				'defaultRobots' => $defaultRobots,
			]
		);
	}

	/**
	 * @return null|string
	 * @throws \Twig_Error_Loader
	 * @throws \yii\base\Exception
	 */
	public function getSettingsHtml ()
	{
		$view = \Craft::$app->view;
		$namespace = $view->namespaceInputId('');

		$view->registerAssetBundle(SeoFieldSettingsAsset::class);
		$view->registerJs('new SeoFieldSettings("' . $namespace . '");');

		return $view->renderTemplate(
			'seo/_seo/settings',
			array_merge(
				[
					'settings' => $this,
					'globalSettings' => Seo::$i->getSettings(),
				],
				Seo::getFieldTypeSettingsVariables()
			)
		);
	}

	public function getSearchKeywords ($value, ElementInterface $element): string {
		/** @var SeoData $value */
		return $value->title . ' ' . $value->description;
	}

	public function getTableAttributeHtml (
		$value,
		ElementInterface $element
	): string {
		/** @var SeoData $value */
		switch ($value->score) {
			case 'poor':
				return '';
				break;
			case 'average':
				return '';
				break;
			case 'good':
				return '';
				break;
			default:
				return '';
		}
	}

}


<?php

namespace ether\seo\services;

use craft\base\Component;
use craft\base\Element;
use craft\base\Field;
use craft\db\Migration;
use craft\db\Query;
use craft\helpers\Json;
use ether\seo\fields\SeoField;
use ether\seo\models\data\SeoData;
use ether\seo\Seo;

class SeoService extends Component
{

	// Actions
	// =========================================================================

	/**
	 * Adds the `X-Robots-Tag` header to the request if needed.
	 */
	public function injectRobots ()
	{
		$headers = \Craft::$app->getResponse()->getHeaders();

		// If devMode always noindex
		if (\Craft::$app->config->general->devMode)
		{
			$headers->set('x-robots-tag', 'none, noimageindex');
			return;
		}

		list($field, $element) = $this->_getElementAndSeoFields();

		// Robots
		$robots = $field->robots;

		if ($robots !== null)
			$headers->set('x-robots-tag', $robots);

		// Get Expiry Date
		/** @var \DateTime $expiry */
		if (isset($element->expiryDate))
			$expiry = $element->expiryDate->format(\DATE_RFC850);
		else
			$expiry = null;

		// If we've got an expiry time, add an additional header
		if ($expiry)
			$headers->add('x-robots-tag', 'unavailable_after: ' . $expiry);
	}

	public function injectCanonical ()
	{
		list($field) = $this->_getElementAndSeoFields();

		\Craft::$app->getResponse()->getHeaders()->add(
			'Link',
			'<' . $field->canonical . '>; rel="canonical"'
		);
	}

	// Helpers
	// =========================================================================

	private function _getElementAndSeoFields ()
	{
		static $element = null;
		static $field = null;

		if ($element !== null)
			return [$field, $element];

		try {
			$resolve = \Craft::$app->request->resolve();
		} catch (\Exception $e) {
			$resolve = [null, []];
		}

		$resolve   = $resolve[1];
		$variables = array_key_exists('variables', $resolve)
			? $resolve['variables']
			: [];
		$handle = null;

		// Get all available "top-level" SEO fields
		foreach ($variables as $variable)
		{
			if (!is_subclass_of($variable, Element::class))
				continue;

			/** @var Element $variable */
			$element = $variable;

			/** @var Field $field */
			foreach ($variable->fieldLayout->getFields() as $field)
			{
				if (get_class($field) !== SeoField::class)
					continue;

				$handle = $field->handle;
				break;
			}

			break;
		}

		if ($handle)
			$field = $element->{$handle};
		else
			$field = new SeoData();

		return [$field, $element];
	}

}


<?php
/**
 * SEO for Craft CMS
 *
 * @link      https://ethercreative.co.uk
 * @copyright Copyright (c) 2018 Ether Creative
 */

namespace ether\seo\web\twig;

use ether\seo\models\data\SeoData;

/**
 * Class Extension
 *
 * @author  Ether Creative
 * @package ether\seo\web\twig
 */
class Extension extends \Twig_Extension
{

	public function getFunctions ()
	{
		return [
			new \Twig_Function(
				'getSeoField',
				[$this, 'getSeoField'],
				['needs_context' => true]
			),
		];
	}

	// Functions
	// =========================================================================

	public function getSeoField ($ctx, $handle = 'seo')
	{
		try {
			$seo = null;

			if (isset($ctx[$handle]))
				$seo = $ctx[$handle];

			elseif (isset($ctx['entry']) && isset($ctx['entry'][$handle]))
				$seo = $ctx['entry'][$handle];

			elseif (isset($ctx['product']) && isset($ctx['product'][$handle]))
				$seo = $ctx['product'][$handle];

			elseif (isset($ctx['category']) && isset($ctx['category'][$handle]))
				$seo = $ctx['category'][$handle];

			if ($seo instanceof SeoData)
				return $seo;

		} catch (\Exception $e) {
			return null;
		}

		return null;
	}

}


<?php
/**
 * SEO for Craft CMS
 *
 * @link      https://ethercreative.co.uk
 * @copyright Copyright (c) 2018 Ether Creative
 */

namespace ether\seo\models\data;

use craft\base\Element;
use craft\base\ElementInterface;
use craft\helpers\Json;
use craft\web\View;
use ether\seo\fields\SeoField;
use ether\seo\models\Settings;
use ether\seo\Seo;
use yii\base\BaseObject;

/**
 * Class SeoData
 *
 * @property string|array $title
 * @property string       $description
 *
 * @author  Ether Creative
 * @package ether\seo\models\data
 */
class SeoData extends BaseObject
{

	// Properties
	// =========================================================================

	// Properties: Public
	// -------------------------------------------------------------------------

	/** @var array|string */
	public $titleRaw = [];

	public $descriptionRaw = '';

	/** @var array */
	public $keywords = [];

	/** @var string */
	public $score = 'neutral';

	/** @var array */
	public $social = [
		'twitter'  => null,
		'facebook' => null,
	];

	/** @var array */
	public $advanced = [
		'robots' => [],
		'canonical' => null,
	];

	// Properties: Private
	// -------------------------------------------------------------------------

	/** @var string */
	private $_handle;

	/** @var Element */
	private $_element;

	/** @var array */
	private $_overrideObject = [];

	/** @var array */
	private $_fieldSettings;

	/** @var Settings */
	private $_seoSettings;

	/** @var string */
	private $_titleTemplate = '';

	/** @var string */
	private $_renderedTitle;

	/** @var string */
	private $_descriptionTemplate = '';

	/** @var string */
	private $_renderedDescription;

	// Constructor
	// =========================================================================

	public function __construct (SeoField $seo = null, ElementInterface $element = null, array $config = [])
	{
		// TODO: There is a LOT going on here, needs to be improved

		$this->_handle = $seo !== null ? $seo->handle : null;
		$this->_element = $element;
		$this->_seoSettings = Seo::$i->getSettings();
		$this->_fieldSettings =
			$seo === null
				? SeoField::$defaultFieldSettings
				: $seo->getSettings();

		// Backwards compatibility for titles in SEO v3.4.x or lower
		if (
			isset($config['titleRaw']) &&
			is_array($config['titleRaw']) &&
			isset($config['titleRaw'][0])
		) {
			$config['titleRaw'] = $config['titleRaw'][0];
		}

		if (
			isset($config['titleRaw']) &&
			(
				($hasTitle = isset($config['title'])) ||
				!is_array($config['titleRaw'])
			)
		) {
			$template = $this->_getSetting('title');
			$title    = $hasTitle ? $config['title'] : $config['titleRaw'];

			// Find the first unlocked token
			if (is_string($title) && !empty($template))
			{
				foreach ($template as $index => $tmpl)
				{
					if ($tmpl['locked'] != false && $tmpl['locked'] !== '0')
						continue;

					$title = [$template[$index]['key'] => $title];
					break;
				}
			}

			$config['titleRaw'] = $title;
			unset($config['title']);
		}

		if (array_key_exists('title', $config))
		{
			$config['titleRaw'] = $config['title'];
			unset($config['title']);
		}

		if (array_key_exists('titleRaw', $config))
		{
			if (is_string($config['titleRaw']))
				$config['titleRaw'] = [$config['titleRaw']];
			else
				$config['titleRaw'] = array_filter($config['titleRaw']);
		}

		// Backwards compatibility for descriptions in SEO v3.4.x or lower
		if (isset($config['description']))
		{
			$config['descriptionRaw'] = $config['description'];
			unset($config['description']);
		}

		// Backwards compatibility for Keywords in SEO v1 / Craft v2
		if (isset($config['keyword']))
		{
			if (!empty($config['keyword'])) {
				$config['keywords'] = [[
					'keyword' => $config['keyword'],
					'rating' => [
						''     => 'neutral',
						'good' => 'good',
						'ok'   => 'average',
						'bad'  => 'poor',
					][$config['score']],
				]];
			}

			unset($config['keyword']);
		}

		// Decode keywords JSON string (when saving)
		if (isset($config['keywords']) && is_string($config['keywords']))
			$config['keywords'] = Json::decodeIfJson($config['keywords']);

		// Merge social w/ defaults
		if (isset($config['social']))
		{
			$this->social = array_merge($this->social, $config['social']);
			unset($config['social']);
		}

		// Merge advanced w/ defaults
		if (isset($config['advanced']))
		{
			$this->advanced = array_merge($this->advanced, $config['advanced']);
			unset($config['advanced']);
		}

		// Override Object
		if (isset($config['overrideObject']))
		{
			$this->_overrideObject = $config['overrideObject'];
			unset($config['overrideObject']);
		}

		parent::__construct($config);
	}

	// Init
	// =========================================================================

	public function init ()
	{

		// Title
		// ---------------------------------------------------------------------

		$twig     = \Craft::$app->view->twig;
		$title    = array_filter($this->titleRaw);
		$template = $this->_getSetting('title');

		if (is_string($title)) $this->_titleTemplate = $title;
		else
		{
			$this->_titleTemplate = implode(
				'',
				array_map(
					function ($a) use ($twig, $title) {
						return array_key_exists($a['key'], $title)
							? twig_escape_filter($twig, $title[$a['key']])
							: $a['template'];
					},
					$template
				)
			);
		}

		// Description
		// ---------------------------------------------------------------------

		if (!empty($this->descriptionRaw))
			$this->_renderedDescription = $this->descriptionRaw;

		$this->_descriptionTemplate = $this->_getSetting('description');

		// Keywords
		// ---------------------------------------------------------------------

		if (!is_array($this->keywords))
			$this->keywords = [];

		// Social
		// ---------------------------------------------------------------------

		$fallback = $this->_getSocialFallback();

		foreach ($this->social as $key => $value)
		{
			if ($value === null)
				$this->social[$key] = new SocialData($key, $fallback);
			elseif (is_array($value))
				// FIXME
				$this->social[$key] = new SocialData($key, $fallback, $value);
		}

		// Robots
		// ---------------------------------------------------------------------

		// Fallback if robots empty
		if (empty($this->advanced['robots']))
			$this->advanced['robots'] = Seo::$i->getSettings()->robots ?? [];

		// Filter out empty robots
		$this->advanced['robots'] = array_filter($this->advanced['robots']);
	}

	// Getters / Setters
	// =========================================================================

	/**
	 * @return array|string
	 * @throws \Throwable
	 */
	public function getTitle ()
	{
		if (!$this->_renderedTitle) {
			$this->_renderedTitle = $this->_render(
				$this->_titleTemplate,
				$this->_getVariables()
			);
		}

		return new \Twig_Markup($this->_renderedTitle, 'utf8');
	}

	/**
	 * @return array
	 * @throws \Throwable
	 */
	public function getTitleAsTokens ()
	{
		if (
			$this->_element === null
			|| $this->_handle === null
			|| !\Craft::$app->request->isCpRequest
		) return [];

		$template = $this->_getSetting('title');
		$elementArray = $this->_getVariables();

		$tokens = [];

		foreach ($template as $token)
		{
			$tokens[$token['key']] = new \Twig_Markup(
				$this->_render(
					$token['template'],
					$elementArray
				),
				'utf8'
			);
		}

		return $tokens;
	}

	/**
	 * @return string
	 * @throws \Throwable
	 */
	public function getDescription ()
	{
		if (!$this->_renderedDescription) {
			$this->_renderedDescription = $this->_render(
				$this->_descriptionTemplate,
				$this->_getVariables()
			);
		}

		return new \Twig_Markup($this->_renderedDescription, 'utf8');
	}

	/**
	 * @return string
	 */
	public function getRobots ()
	{
		if (\Craft::$app->config->general->devMode)
			return 'none, noimageindex';

		if (!empty($this->advanced['robots']))
			return implode(', ', $this->advanced['robots']);

		return null;
	}

	/**
	 * @return string|null
	 */
	public function getExpiry ()
	{
		if (!$this->_element || !isset($this->_element->expiryDate))
			return null;

		return $this->_element->expiryDate->format(DATE_RFC850);
	}

	/**
	 * Returns the canonical URL (falling back to the current URL if not set)
	 *
	 * @return string
	 */
	public function getCanonical ()
	{
		if (empty($this->advanced['canonical']))
			return \Craft::$app->request->absoluteUrl;

		return $this->advanced['canonical'];
	}

	// Helpers
	// =========================================================================

	private function _getSetting ($handle)
	{
		return empty($this->_fieldSettings[$handle])
			? $this->_seoSettings[$handle]
			: $this->_fieldSettings[$handle];
	}

	/**
	 * Gets the social metadata fallback
	 *
	 * @return array
	 */
	private function _getSocialFallback ()
	{
		$image = null;

		$assets = \Craft::$app->assets;

		$fieldFallback = $this->_fieldSettings['socialImage'];

		if (!empty($fieldFallback))
			$image = $assets->getAssetById((int)$fieldFallback[0]);

		else {
			$seoFallback = $this->_seoSettings['socialImage'];

			if (!empty($seoFallback))
				$image = $assets->getAssetById((int)$seoFallback[0]);
		}

		return [
			'title'       => $this->title,
			'description' => $this->description,
			'image'       => $image,
		];
	}

	/**
	 * Returns an array for variables for rendering
	 *
	 * @return array
	 */
	private function _getVariables ()
	{
		$variables = $this->_overrideObject;

		if ($this->_element !== null)
		{
			foreach ($this->_element->attributes() as $name)
				if ($name !== $this->_handle)
					$variables[$name] = $this->_element->$name;

			$variables = array_merge(
				$variables,
				$this->_element->toArray($this->_element->extraFields())
			);
		}

		return $variables;
	}

	/**
	 * @param $template
	 * @param $variables
	 *
	 * @return string
	 * @throws \Throwable
	 */
	private function _render ($template, $variables)
	{
		$craft = \Craft::$app;

		if ($template === null)
			return '';

		try {
			// If this is a CP request, render the title as if it was the frontend
			if ($craft->request->isCpRequest)
			{
				$site   = $craft->sites->currentSite;
				$tpMode = $craft->view->templateMode;
				$craft->sites->setCurrentSite($this->_element->site);
				$craft->view->setTemplateMode(View::TEMPLATE_MODE_SITE);

				$ret = \Craft::$app->view->renderObjectTemplate(
					$template,
					$variables
				);

				$craft->sites->setCurrentSite($site);
				$craft->view->setTemplateMode($tpMode);
			}
			else
			{
				$ret = \Craft::$app->view->renderObjectTemplate(
					$template,
					$variables
				);
			}
		} catch (\Exception $e) {
			$ret = 'ERROR: ' . $e->getMessage();
		}

		return $ret;
	}

}


<?php

namespace ether\seo\models;

use craft\base\Model;

class Sitemap extends Model
{

	// Props
	// =========================================================================

	/** @var string */
	public $group;

	/** @var string */
	public $url;

	/** @var string */
	public $frequency;

	/** @var float */
	public $priority;

	/** @var bool */
	public $enabled = false;

	// Methods
	// =========================================================================

	public function rules (): array
	{
		$rules = parent::rules();

		$rules[] = [
			['group', 'url', 'frequency', 'priority'],
			'required',
		];

		$rules[] = [
			['group', 'url', 'frequency'],
			'string',
		];

		$rules[] = [
			['priority'],
			'double',
			'min' => 0,
			'max' => 1,
		];

		$rules[] = [
			['enabled'],
			'boolean',
		];

		$rules[] = [
			['group'],
			'in',
			'range' => [
				'sections',
				'categories',
				'customUrls',
			],
		];

		return $rules;
	}

}


<?php

namespace ether\seo\web\twig;

use craft\elements\Asset;
use craft\helpers\Template;
use craft\helpers\UrlHelper;
use ether\seo\fields\SeoField;
use ether\seo\models\data\SeoData;
use Twig_Template;

class Variable
{

	/**
	 * @param string|array $title
	 * @param string $description
	 * @param bool   $_ - Deprecated
	 * @param array  $social
	 *
	 * @return SeoData
	 */
	public function custom (
		$title = '',
		$description = '',
		$_ = null,
		$social = []
	) {
		$config = [
			'titleRaw'    => '',
			'description' => $description,
			'social'      => $social,
		];

		if (is_array($title))
			$config['overrideObject'] = $title;
		else
			$config['titleRaw'] = $title;

		return new SeoData(null, null, $config);
	}

	// Social
	// =========================================================================

	// Social: Images
	// -------------------------------------------------------------------------

	/**
	 * @param Asset|null $image
	 *
	 * @return \Twig_Markup|string
	 * @throws \yii\base\Exception
	 */
	public function twitterImage ($image)
	{
		return $this->_socialImage($image, [
			'width'  => 1200,
			'height' => 675,
		]);
	}

	/**
	 * @param Asset|null $image
	 *
	 * @return \Twig_Markup|string
	 * @throws \yii\base\Exception
	 */
	public function facebookImage ($image)
	{
		return $this->_socialImage($image, [
			'width'  => 1200,
			'height' => 600,
		]);
	}

	/**
	 * @param Asset|null $image
	 * @param array      $transform
	 *
	 * @return \Twig_Markup|string
	 * @throws \yii\base\Exception
	 */
	private function _socialImage ($image, array $transform)
	{
		if (!$image) return '';

		$transformUrl = $image->getUrl($transform);

		if ($transformUrl === null)
			return '';

		if ($transformUrl && strpos($transformUrl, 'http') === false)
			$transformUrl = UrlHelper::urlWithScheme($transformUrl, (\Craft::$app->getRequest()->getIsSecureConnection()? 'https': 'http'));

		return Template::raw($transformUrl);
	}

}


<?php

namespace ether\seo\services;

use craft\base\Component;
use craft\events\ExceptionEvent;
use craft\helpers\UrlHelper;
use ether\seo\records\RedirectRecord;
use yii\web\HttpException;

class RedirectsService extends Component
{

	// Public Methods
	// =========================================================================

	// Event Handlers
	// -------------------------------------------------------------------------

	/**
	 * Handles 404 exceptions
	 *
	 * @param ExceptionEvent $event
	 *
	 * @return void
	 * @throws \yii\base\Exception
	 * @throws \yii\base\ExitException
	 */
	public function onException (ExceptionEvent $event)
	{
		$exception = $event->exception;
		$craft = \Craft::$app;

		if (!($exception instanceof HttpException) || $exception->statusCode !== 404)
			return;

		$path = $craft->request->getFullPath();
		$query = $craft->request->getQueryStringWithoutPath();

		if ($query)
			$path .= '?' . $query;

		if ($redirect = $this->findRedirectByPath($path))
		{
			$event->handled = true;
			$craft->response->redirect($redirect['to'], $redirect['type'])
			                ->send();
			$craft->end();
		}
	}

	// Finders
	// -------------------------------------------------------------------------

	/**
	 * Returns all the redirects
	 *
	 * @param bool $currentSiteOnly
	 *
	 * @return array
	 */
	public function findAllRedirects ($currentSiteOnly = false)
	{
		if ($currentSiteOnly)
			return RedirectRecord::find()->where(
				'[[siteId]] IS NULL OR [[siteId]] = ' .
				\Craft::$app->sites->currentSite->id
			)->orderBy('siteId asc')->all();

		return array_reduce(
			RedirectRecord::find()->all(),
			function ($a, RedirectRecord $record) {
				$a[$record->siteId ?? 'null'][] = $record;
				return $a;
			},
			array_reduce(
				\Craft::$app->sites->allSiteIds,
				function ($a, $id) {
					$a[$id] = [];
					return $a;
				},
				[]
			)
		);
	}

	/**
	 * Finds a redirect matching the given path
	 *
	 * @param $path
	 *
	 * @return array|bool
	 * @throws \yii\base\Exception
	 */
	public function findRedirectByPath ($path)
	{
		$redirects = $this->findAllRedirects(true);

		foreach ($redirects as $redirect)
		{
			$to = false;

			if (trim($redirect['uri'], '/') == $path)
				$to = $redirect['to'];

			elseif ($uri = $this->_isRedirectRegex($redirect['uri']))
				if (preg_match($uri, $path))
					$to = preg_replace($uri, $redirect['to'], $path);

			if ($to)
			{
				return [
					'to' => strpos($to, '://') !== false
						? $to
						: UrlHelper::siteUrl($to),
					'type' => $redirect['type'],
				];
			}
		}

		return false;
	}

	// Save / Update / Delete
	// -------------------------------------------------------------------------

	/**
	 * Saves the redirect
	 *
	 * @param string   $uri
	 * @param string   $to
	 * @param string   $type
	 * @param null     $siteId
	 * @param int|null $id
	 *
	 * @return array|int|string
	 */
	public function save ($uri, $to, $type, $siteId = null, $id = null)
	{
		if ($siteId === 'null')
			$siteId = null;

		if ($id)
		{
			$record = RedirectRecord::findOne(compact('id'));

			if (!$record)
				return 'Unable to find redirect with ID: ' . $id;
		}
		else
		{
			$existing = RedirectRecord::findOne(compact('uri', 'siteId'));

			if ($existing)
				return 'A redirect with that URI already exists!';

			$record = new RedirectRecord();
		}

		$record->uri    = $uri;
		$record->to     = $to;
		$record->type   = $type;
		if ($siteId !== false)
			$record->siteId = $siteId;

		if (!$record->save())
			return $record->getErrors();

		return $record->id;
	}

	/**
	 * Bulk creates redirects
	 *
	 * @param      $redirects
	 * @param      $separator
	 * @param      $type
	 * @param      $siteId
	 *
	 * @return array
	 */
	public function bulk ($redirects, $separator, $type, $siteId)
	{
		$rawRedirects = array_map(function ($line) use ($separator) {
			return str_getcsv($line, $separator);
		}, explode(PHP_EOL, $redirects));

		$newFormatted = [];

		foreach ($rawRedirects as $redirect)
		{
			$record = new RedirectRecord();
			$record->uri = $redirect[0];
			$record->to = $redirect[1];
			$record->type = array_key_exists(2, $redirect) ? $redirect[2] : $type;
			$record->siteId = $siteId;
			$record->save();

			$newFormatted[] = [
				'id'     => $record->id,
				'uri'    => $record->uri,
				'to'     => $record->to,
				'type'   => $record->type,
				'siteId' => $record->siteId,
			];
		}

		return [$newFormatted, false];
	}

	/**
	 * Deletes the redirect with the given ID
	 *
	 * @param int $id
	 *
	 * @return bool|string
	 * @throws \Exception
	 * @throws \Throwable
	 * @throws \yii\db\StaleObjectException
	 */
	public function delete ($id)
	{
		$redirect = RedirectRecord::findOne(compact('id'))->delete();

		if ($redirect === false)
			return 'Unable find redirect with ID: ' . $id;

		return false;
	}

	// Private Methods
	// =========================================================================

	// Helpers
	// -------------------------------------------------------------------------

	/**
	 * Checks if the redirect URI is a regex
	 *
	 * @param string $uri
	 *
	 * @return bool|mixed|string
	 */
	private function _isRedirectRegex ($uri)
	{
		// If the URI doesn't look like a regex...
		if (preg_match('/\/(.*)\/([g|m|i|x|X|s|u|U|A|J|D]+)/m', $uri) === 0)
		{
			// Escape all non-escaped `?` not inside parentheses
			$i = preg_match_all(
				'/(?<!\\\\)\?(?![^(]*\))/',
				$uri,
				$matches,
				PREG_OFFSET_CAPTURE
			);

			while ($i--)
			{
				$x   = $matches[0][$i][1];
				$uri = substr_replace($uri, '\?', $x, 1);
			}

			// Escape all non-escaped `/` not inside parentheses
			$i = preg_match_all(
				'/(?<!\\\\)\/(?![^(]*\))/',
				$uri,
				$matches,
				PREG_OFFSET_CAPTURE
			);

			while ($i--)
			{
				$x   = $matches[0][$i][1];
				$uri = substr_replace($uri, '\/', $x, 1);
			}
		}

		// Check if contains a valid regex
		if (@preg_match($uri, null) === false)
			$uri = '/^' . $uri . '$/i';

		if (@preg_match($uri, null) !== false)
			return $uri;

		return false;
	}

}


<?php

namespace ether\seo;

use craft\base\Plugin;
use craft\events\ExceptionEvent;
use craft\events\RegisterComponentTypesEvent;
use craft\events\RegisterUrlRulesEvent;
use craft\events\RegisterUserPermissionsEvent;
use craft\helpers\UrlHelper;
use craft\services\Fields;
use craft\services\UserPermissions;
use craft\web\Application;
use craft\web\ErrorHandler;
use craft\web\twig\variables\CraftVariable;
use craft\web\UrlManager;
use craft\web\View;
use ether\seo\fields\SeoField;
use ether\seo\listeners\GetCraftQLSchema;
use ether\seo\models\Settings;
use ether\seo\services\RedirectsService;
use ether\seo\services\SeoService;
use ether\seo\services\SitemapService;
use ether\seo\services\UpgradeService;
use ether\seo\web\twig\Extension;
use ether\seo\web\twig\Variable;
use yii\base\Event;

/**
 * Class Seo
 *
 * @package ether\seo
 *
 * @property SeoService         $seo
 * @property SitemapService     $sitemap
 * @property RedirectsService   $redirects
 * @property UpgradeService     $upgrade
 */
class Seo extends Plugin
{

	// Variables
	// =========================================================================

	/** @var Seo */
	public static $i;

	public $hasCpSection        = true;
	public $hasCpSettings       = true;

	public $changelogUrl =
		'https://raw.githubusercontent.com/ethercreative/seo/v3/CHANGELOG.md';
	public $downloadUrl  =
		'https://github.com/ethercreative/seo/archive/v3.zip';
	public $documentationUrl =
		'https://github.com/ethercreative/seo/blob/v3/README.md';

	public $schemaVersion = '3.1.0';

	// Craft
	// =========================================================================

	public function init ()
	{
		parent::init();
		self::$i = self::getInstance();

		$craft = \Craft::$app;

		// Components
		// ---------------------------------------------------------------------

		$this->setComponents([
			'seo' => SeoService::class,
			'sitemap' => SitemapService::class,
			'redirects' => RedirectsService::class,
			'upgrade' => UpgradeService::class,
		]);

		// Events
		// ---------------------------------------------------------------------

		// User Permissions
		if ($craft->getEdition() !== \Craft::Solo) {
			Event::on(
				UserPermissions::class,
				UserPermissions::EVENT_REGISTER_PERMISSIONS,
				[$this, 'onRegisterPermissions']
			);
		}

		// CP URLs
		Event::on(
			UrlManager::class,
			UrlManager::EVENT_REGISTER_CP_URL_RULES,
			[$this, 'onRegisterCPUrlRules']
		);

		// Site URLs
		Event::on(
			UrlManager::class,
			UrlManager::EVENT_REGISTER_SITE_URL_RULES,
			[$this, 'onRegisterSiteUrlRules']
		);

		// Field type
		Event::on(
			Fields::class,
			Fields::EVENT_REGISTER_FIELD_TYPES,
			[$this, 'onRegisterFieldTypes']
		);

		// Variable
		Event::on(
			CraftVariable::class,
			CraftVariable::EVENT_INIT,
			[$this, 'onRegisterVariable']
		);

		// 404 Exceptions
		if (
			$craft->request->isSiteRequest
			&& !$craft->request->isConsoleRequest
			&& !$craft->request->isLivePreview
		) {
			Event::on(
				ErrorHandler::class,
				ErrorHandler::EVENT_BEFORE_HANDLE_EXCEPTION,
				[$this, 'onBeforeHandleException']
			);
		}

		if ($craft->request->isSiteRequest)
		{
			// Request Headers
			Event::on(
				Application::class,
				Application::EVENT_AFTER_REQUEST,
				[$this, 'onAfterRequest']
			);

			// Template Hook
			$craft->view->hook('seo', [$this, 'onRegisterSeoHook']);
		}

		if ($craft->request->isSiteRequest || $craft->request->isCpRequest)
		{
			// Twig Extension
			$craft->view->registerTwigExtension(new Extension());
		}

		// CraftQL Support
		/** @noinspection PhpUndefinedNamespaceInspection */
		/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
		if (class_exists(\markhuot\CraftQL\CraftQL::class)) {
			Event::on(
				SeoField::class,
				'craftQlGetFieldSchema',
				[new GetCraftQLSchema, 'handle']
			);
		}
	}

	public function getCpNavItem ()
	{
		$item = parent::getCpNavItem();
		$currentUser = \Craft::$app->user;

		$subNav = [
			'dashboard' => ['label' => 'Dashboard', 'url' => 'seo'],
		];

		if ($currentUser->getIsAdmin() || $currentUser->checkPermission('manageSitemap'))
			$subNav['sitemap'] =
				['label' => 'Sitemap', 'url' => 'seo/sitemap'];

		if ($currentUser->getIsAdmin() || $currentUser->checkPermission('manageRedirects'))
			$subNav['redirects'] =
				['label' => 'Redirects', 'url' => 'seo/redirects'];

		/*if ($currentUser->getIsAdmin() || $currentUser->checkPermission('manageSchema'))
			$subNav['schema'] =
				['label' => 'Schema', 'url' => 'seo/schema'];*/

		if ($currentUser->getIsAdmin())
			$subNav['settings'] =
				['label' => 'Settings', 'url' => 'seo/settings'];

		$item['subnav'] = $subNav;

		return $item;
	}

	// Craft: Settings
	// -------------------------------------------------------------------------

	protected function createSettingsModel (): Settings
	{
		return new Settings();
	}

	public function getSettingsResponse ()
	{
		// Redirect to our settings page
		\Craft::$app->controller->redirect(
			UrlHelper::cpUrl('seo/settings')
		);
	}

	// Events
	// =========================================================================

	public function onRegisterPermissions (RegisterUserPermissionsEvent $event)
	{
		$event->permissions['SEO'] = [
			'manageSitemap' => [
				'label' => \Craft::t('seo', 'Manage Sitemap'),
			],
			'manageRedirects' => [
				'label' => \Craft::t('seo', 'Manage Redirects'),
			],
//			'manageSchema' => [
//				'label' => \Craft::t('seo', 'Manage Schema'),
//			],
		];
	}

	public function onRegisterCPUrlRules (RegisterUrlRulesEvent $event)
	{
		$event->rules['seo'] = 'seo/seo/index';

		// Sitemap
		// ---------------------------------------------------------------------
		$event->rules['POST seo/sitemap'] = 'seo/sitemap/save';
		$event->rules['seo/sitemap'] = 'seo/sitemap/index';

		// Redirects
		// ---------------------------------------------------------------------
		$event->rules['DELETE seo/redirects'] = 'seo/redirects/delete';
		$event->rules['POST seo/redirects'] = 'seo/redirects/save';
		$event->rules['PUT seo/redirects'] = 'seo/redirects/bulk';
		$event->rules['seo/redirects'] = 'seo/redirects/index';

		// Schema
		// ---------------------------------------------------------------------
//		$event->rules['seo/schema'] = 'seo/schema/index';

		// Settings
		// -------------------------------------------------------------------------
		$event->rules['POST seo/settings'] = 'seo/settings/save';
		$event->rules['seo/settings'] = 'seo/settings/index';
	}

	public function onRegisterSiteUrlRules (RegisterUrlRulesEvent $event)
	{
		$sitemapName = $this->getSettings()->sitemapName;

		$event->rules[$sitemapName . '.xml'] = 'seo/sitemap/xml/index';
		$event->rules[$sitemapName . '_<section:\w*>_<id:\d*>_<page:\d*>.xml'] = 'seo/sitemap/xml/core';
		$event->rules[$sitemapName . '_custom.xml'] = 'seo/sitemap/xml/custom';

		$event->rules['robots.txt'] = 'seo/seo/robots';
	}

	public function onRegisterFieldTypes (RegisterComponentTypesEvent $event)
	{
		$event->types[] = SeoField::class;
	}

	/**
	 * @param Event $event
	 *
	 * @throws \yii\base\InvalidConfigException
	 */
	public function onRegisterVariable (Event $event)
	{
		/** @var CraftVariable $variable */
		$variable = $event->sender;
		$variable->set('seo', Variable::class);
	}

	/**
	 * @param ExceptionEvent $event
	 *
	 * @throws \yii\base\Exception
	 * @throws \yii\base\ExitException
	 */
	public function onBeforeHandleException (ExceptionEvent $event)
	{
		$this->redirects->onException($event);
	}

	/**
	 * Fired after an application request is handled
	 * (but before the response is send)
	 */
	public function onAfterRequest ()
	{
		$this->seo->injectRobots();
		$this->seo->injectCanonical();
	}

	/**
	 * @param $context
	 *
	 * @return string
	 * @throws \Twig_Error_Loader
	 * @throws \yii\base\Exception
	 */
	public function onRegisterSeoHook (&$context)
	{
		$craft = \Craft::$app;
		$metaTemplateName = $this->getSettings()['metaTemplate'];

		if ($metaTemplateName)
			return $craft->view->renderTemplate(
				$metaTemplateName,
				$context
			);

		$oldTemplateMode = $craft->view->getTemplateMode();
		$craft->view->setTemplateMode(View::TEMPLATE_MODE_CP);
		$rendered = $craft->view->renderTemplate(
			'seo/_seo/meta',
			$context
		);
		$craft->view->setTemplateMode($oldTemplateMode);
		return $rendered;
	}

	// Misc
	// =========================================================================

	public static function getFieldTypeSettingsVariables ()
	{
		$volumes = \Craft::$app->volumes->getPublicVolumes();

		return compact(
			'volumes'
		);
	}

}


<?php

namespace ether\seo\services;

use craft\base\Component;
use craft\base\Element;
use craft\db\Query;
use craft\elements\Category;
use craft\elements\db\CategoryQuery;
use craft\elements\db\EntryQuery;
use craft\elements\Entry;
use craft\helpers\DateTimeHelper;
use craft\helpers\UrlHelper;
use craft\models\CategoryGroup;
use craft\models\CraftIdToken;
use craft\models\Section;
use ether\seo\fields\SeoField;
use ether\seo\models\data\SeoData;
use ether\seo\models\Settings;
use ether\seo\records\SitemapRecord;
use ether\seo\Seo;
use yii\db\Exception;
use yii\log\Logger;

class SitemapService extends Component
{

	// Get & Save Sitemap
	// =========================================================================

	public function getSitemap ()
	{
		$sitemapRaw = SitemapRecord::find()->all();
		$sitemap = [];

		/** @var SitemapRecord $row */
		foreach ($sitemapRaw as $row)
		{
			if (!array_key_exists($row->group, $sitemap))
				$sitemap[$row->group] = [];

			if ($row->group === 'customUrls')
				$sitemap[$row->group][] = $row;
			else
				$sitemap[$row->group][$row->url] = $row;
		}

		return $sitemap;
	}

	public function saveSitemap ($data)
	{
		$oldSitemap = $this->getSitemap();
		$newSitemap = $data;

		// Delete removed rows
		// ---------------------------------------------------------------------
		$newById = [];
		$oldById = [];

		$newRecordsRaw = [];

		foreach ($newSitemap as $group => $rows)
		{
			foreach ((array)$rows as $new)
			{
				if (!is_array($new))
					continue;

				$new['group'] = $group;

				if (!array_key_exists('id', $new))
					continue;

				if ($new['id'] !== '-1') $newById[$new['id']] = $new;
				else $newRecordsRaw[] = $new;
			}
		}

		$idsToDelete = [];

		foreach ($oldSitemap as $group => $rows)
		{
			foreach ($rows as $old)
			{
				if (array_key_exists($old['id'], $newById))
					$oldById[$old['id']] = $old;
				else
					$idsToDelete[] = $old['id'];
			}
		}

		if (!empty($idsToDelete))
		{
			try {
				\Craft::$app->db->createCommand()->delete(
					SitemapRecord::$tableName,
					['in', 'id', $idsToDelete]
				)->execute();
			} catch (Exception $e) {
				\Craft::$app->log->logger->log(
					$e->getMessage(),
					LOG_ERR,
					'seo'
				);
				return false;
			}
		}

		// Update current rows
		// ---------------------------------------------------------------------
		foreach ($newById as $new)
		{
			$old = $oldById[$new['id']];

			if (
				$old['url'] !== $new['url'] ||
				$old['frequency'] !== $new['frequency'] ||
				$old['priority'] !== $new['priority'] ||
				$old['enabled'] !== !!$new['enabled']
			) {
				$old->setAttribute('url', $new['url']);
				$old->setAttribute('frequency', $new['frequency']);
				$old->setAttribute('priority', $new['priority']);
				$old->setAttribute('enabled', !!$new['enabled']);
				$old->save();
			}
		}

		// Add new rows
		// ---------------------------------------------------------------------
		foreach ($newRecordsRaw as $new)
		{
			$record = new SitemapRecord();
			$record->setAttribute('url', $new['url']);
			$record->setAttribute('frequency', $new['frequency']);
			$record->setAttribute('priority', $new['priority']);
			$record->setAttribute('enabled', !!$new['enabled']);
			$record->setAttribute('group', $new['group']);
			$record->save();
		}

		return true;
	}

	// Get Sections
	// =========================================================================

	public function getValidSections ()
	{
		return array_filter(
			\Craft::$app->sections->getAllSections(),
			[$this, '_filterOutNoUrls']
		);
	}

	public function getValidCategories ()
	{
		return array_filter(
			\Craft::$app->categories->getAllGroups(),
			[$this, '_filterOutNoUrls']
		);
	}

	// Sitemap XML
	// =========================================================================

	/** @var \DOMDocument */
	private $_document;

	/** @var \DOMElement */
	private $_urlSet;

	// Index
	// -------------------------------------------------------------------------

	/** @var \DOMElement */
	private $_index;

	/**
	 * @throws \yii\base\Exception
	 */
	public function index ()
	{
		$this->_createDocument(false);

		// Add the Sitemap Index
		$this->_index = $this->_document->createElement('sitemapindex');
		$this->_index->setAttribute(
			'xmlns',
			'http://www.sitemaps.org/schemas/sitemap/0.9'
		);
		$this->_index->setAttribute(
			'xmlns:xhtml',
			'http://www.w3.org/1999/xhtml'
		);
		$this->_document->appendChild($this->_index);

		// Get the saved sitemap data
		$sitemapData = $this->getSitemap();

		// Generate Loop: Sections
		$this->_generateLoop(
			'sections',
			$this->getValidSections(),
			$sitemapData
		);

		// Generate Loop: Categories
		$this->_generateLoop(
			'categories',
			$this->getValidCategories(),
			$sitemapData
		);

		// Generate: Custom
		if (array_key_exists('customUrls', $sitemapData))
			$this->_generateIndex('custom', 0);

		return $this->_document->saveXML();
	}

	// Core
	// -------------------------------------------------------------------------

	/**
	 * @param array $variables
	 *
	 * @return string
	 * @throws \yii\base\Exception
	 */
	public function core (array $variables)
	{
		$this->_createDocument();
		$sitemapData = $this->getSitemap();
		$craft = \Craft::$app;

		if (!array_key_exists($variables['section'], $sitemapData))
			goto out;

		$sitemapSection = $sitemapData[$variables['section']];

		if (!array_key_exists($variables['id'], $sitemapSection))
			goto out;

		$sitemapSectionById = $sitemapSection[$variables['id']];

		if (!$sitemapSectionById['enabled'])
			goto out;

		/** @var Element|null $type */
		$type = null;
		$idHandle = null;

		switch ($variables['section'])
		{
			case 'sections':
				$type = Entry::instance();
				$idHandle = 'sectionId';
				break;

			case 'categories':
				$type = Category::instance();
				$idHandle = 'groupId';
				break;

			default:
				goto out;
		}

		$settings = Seo::$i->getSettings();

		$elements = $type::find();
		$elements->{$idHandle} = $variables['id'];
		$elements->siteId = \Craft::$app->sites->currentSite->id;
		$elements->limit = $settings->sitemapLimit;
		$elements->offset = $settings->sitemapLimit * $variables['page'];

		$currentLocale = $craft->locale->id;
		$availableLocales = $craft->i18n->getSiteLocaleIds();

		if (($key = array_search($currentLocale, $availableLocales)) !== false)
			unset($availableLocales[$key]);

		$seoFieldHandle = null;
		if ($first = $elements->one())
		{
			$fieldLayout =
				$variables['section'] === 'categories'
					? $first->fieldLayout
					: $first->type->fieldLayout;

			foreach ($fieldLayout->getFields() as $field)
				if (get_class($field) === SeoField::class)
					$seoFieldHandle = $field->handle;
		}

		foreach ($elements->all() as $item)
		{
			if ($item->url === null)
				continue;

			if ($seoFieldHandle !== null) {
				/** @var SeoData $seoField */
				$seoField = $item->$seoFieldHandle;
				if ($robots = $seoField->advanced['robots'])
					if (in_array('noindex', $robots))
						continue;
			}

			$url = $this->_document->createElement('url');
			$this->_urlSet->appendChild($url);

			$loc = $this->_document->createElement(
				'loc',
				$item->url
			);

			$mod = $this->_document->createElement(
				'lastmod',
				DateTimeHelper::toIso8601($item->dateUpdated)
			);

			$freq = $this->_document->createElement(
				'changefreq',
				$sitemapSectionById['frequency']
			);

			$priority = $this->_document->createElement(
				'priority',
				$sitemapSectionById['priority']
			);

			$url->appendChild($loc);
			$url->appendChild($mod);
			$url->appendChild($freq);
			$url->appendChild($priority);

			$enabledLookup =
				(new Query())->select(['siteId', 'uri'])
				             ->from('{{%elements_sites}}')
				             ->where('[[elementId]] = ' . $item->id)
				             ->andWhere('enabled = true')
				             ->all();

			$enabledLookup = array_reduce(
				$enabledLookup,
				function ($a, $b) {
					$uri = $b['uri'];
					$a[$b['siteId']] = $uri === '__home__' ? '' : $uri;
					return $a;
				},
				[]
			);

			foreach ($item->supportedSites as $siteId)
			{
				$id = is_numeric($siteId) ? $siteId : $siteId['siteId'];
				$site = $id ? $craft->sites->getSiteById($id) : \Craft::$app->sites->currentSite;
				$lang = $site->language;

				if (!in_array($lang, $availableLocales))
					continue;

				if (!array_key_exists($id, $enabledLookup))
					continue;

				$link = UrlHelper::siteUrl($enabledLookup[$id], null, null, $id);

				$alt = $this->_document->createElement('xhtml:link');
				$alt->setAttribute('rel', 'alternate');
				$alt->setAttribute(
					'hreflang',
					str_replace('_', '-', $lang)
				);
				$alt->setAttribute('href', $link);

				$url->appendChild($alt);
			}
		}

		out:
		return $this->_document->saveXML();
	}

	// Custom
	// -------------------------------------------------------------------------

	public function custom ()
	{
		$this->_createDocument();
		$sitemapData = $this->getSitemap();

		if (!array_key_exists('customUrls', $sitemapData))
			return $this->_document->saveXML();

		foreach ($sitemapData['customUrls'] as $custom) if ($custom->enabled)
		{
			$url = $this->_document->createElement('url');
			$loc = $this->_document->createElement(
				'loc',
				UrlHelper::url($custom->url)
			);
			$frequency = $this->_document->createElement(
				'changefreq',
				$custom->frequency
			);
			$priority = $this->_document->createElement(
				'priority',
				$custom->priority
			);

			$url->appendChild($loc);
			$url->appendChild($frequency);
			$url->appendChild($priority);

			$this->_urlSet->appendChild($url);
		}

		return $this->_document->saveXML();
	}

	// Helpers
	// =========================================================================

	/**
	 * @param Section|CategoryGroup $thing
	 *
	 * @return bool
	 */
	private function _filterOutNoUrls ($thing)
	{
		foreach ($thing->getSiteSettings() as $siteSettings)
			if ($siteSettings->hasUrls)
				return true;

		return false;
	}

	// Helpers: XML
	// -------------------------------------------------------------------------

	/**
	 * Creates the XML document
	 *
	 * @param bool $withUrlSet - Will append the URLSet if true
	 */
	private function _createDocument ($withUrlSet = true)
	{
		// Create the XML Document
		$document = new \DOMDocument('1.0', 'utf-8');

		// Pretty print for debugging
		if (\Craft::$app->config->general->devMode)
			$document->formatOutput = true;

		if ($withUrlSet)
		{
			$urlSet = $document->createElement('urlset');
			$urlSet->setAttribute(
				'xmlns',
				'http://www.sitemaps.org/schemas/sitemap/0.9'
			);
			$urlSet->setAttribute(
				'xmlns:xhtml',
				'http://www.w3.org/1999/xhtml'
			);
			$document->appendChild($urlSet);
			$this->_urlSet = $urlSet;
		}

		$this->_document = $document;
	}

	/**
	 * Get's the latest updated element date for the given element type and
	 * group / section ID
	 *
	 * @param Element $type - The Element Type
	 * @param int     $id - The section or group ID
	 *
	 * @return \DateTime|string
	 */
	private function _getUpdated (Element $type, $id)
	{
		/** @var EntryQuery|CategoryQuery $criteria */
		$criteria = $type::find();

		$this->_setCriteriaIdByType($criteria, $type, $id);

		$criteria->limit = 1;

		$element = $criteria->one();
		return $element ? $element->dateUpdated->format('c') : '';
	}

	/**
	 * Get's the page count for the given element type and group / section ID
	 *
	 * @param Element $type - The Element Type
	 * @param int     $id - The section or group ID
	 *
	 * @return float
	 */
	private function _getPageCount (Element $type, $id)
	{
		/** @var EntryQuery|CategoryQuery $criteria */
		$criteria = $type::find();
		$this->_setCriteriaIdByType($criteria, $type, $id);

		$sitemapLimit = Seo::$i->getSettings()->sitemapLimit;

		return ceil($criteria->count() / $sitemapLimit);
	}

	/**
	 * Sets the section or group ID on the criteria according to the
	 * given element type
	 *
	 * @param EntryQuery|CategoryQuery $criteria - The criteria
	 * @param Element                  $type - The element type
	 * @param int                      $id - The section or group ID
	 */
	private function _setCriteriaIdByType ($criteria, Element $type, $id)
	{
		switch ($type::className()) {
			case 'Entry':
				$criteria->sectionId = $id;
				break;
			case 'Category':
				$criteria->groupId = $id;
				break;
		}
	}

	// Helpers: XML Index
	// -------------------------------------------------------------------------

	/**
	 * @param $handle
	 * @param $data
	 * @param $sitemapData
	 *
	 * @throws \yii\base\Exception
	 */
	private function _generateLoop ($handle, $data, $sitemapData)
	{
		if (!array_key_exists($handle, $sitemapData))
			return;

		foreach ($data as $item)
			if (array_key_exists($item['id'], $sitemapData[$handle]))
				if ($sitemapData[$handle][$item['id']]->enabled)
					$this->_generateIndex($handle, $item['id']);
	}

	/**
	 * @param $group
	 * @param $id
	 *
	 * @throws \yii\base\Exception
	 */
	private function _generateIndex ($group, $id)
	{
		switch ($group) {
			case 'custom':
				$last = DateTimeHelper::toIso8601(
					SitemapRecord::find()->one()->dateUpdated
				);
				$pages = 1;
				break;

			case 'sections':
				$last = $this->_getUpdated(Entry::instance(), $id);
				$pages = $this->_getPageCount(Entry::instance(), $id);
				break;

			case 'categories':
				$last = $this->_getUpdated(Category::instance(), $id);
				$pages = $this->_getPageCount(Category::instance(), $id);
				break;

			default:
				$last = DateTimeHelper::currentUTCDateTime()->format('c');
				$pages = 1;
		}

		for ($i = 0; $i < $pages; ++$i)
		{
			$sitemap = $this->_document->createElement('sitemap');
			$this->_index->appendChild($sitemap);

			$loc = $this->_document->createElement(
				'loc',
				$this->_indexUrl($group, $id, $i)
			);
			$sitemap->appendChild($loc);

			$lastMod = $this->_document->createElement('lastmod', $last);
			$sitemap->appendChild($lastMod);
		}
	}

	/**
	 * @param $group
	 * @param $id
	 * @param $page
	 *
	 * @return string
	 * @throws \yii\base\Exception
	 */
	private function _indexUrl ($group, $id, $page)
	{
		$sitemapName = Seo::$i->getSettings()->sitemapName;

		return UrlHelper::siteUrl(
			$sitemapName . '_' . $group
			. ($id > 0 ? '_' . $id : '')
			. ($id > 0 ? '_' . $page : '')
			. '.xml'
		);
	}

}


<?php

namespace ether\seo\controllers\sitemap;

use craft\web\Controller;
use ether\seo\Seo;
use yii\web\Response;

class XmlController extends Controller
{

	protected $allowAnonymous = true;

	/**
	 * @throws \yii\base\Exception
	 */
	public function actionIndex ()
	{
		return $this->_asXml(Seo::$i->sitemap->index());
	}

	public function actionCore ()
	{
		return $this->_asXml(
			Seo::$i->sitemap->core(\Craft::$app->urlManager->getRouteParams())
		);
	}

	public function actionCustom ()
	{
		return $this->_asXml(Seo::$i->sitemap->custom());
	}

	// Helpers
	// =========================================================================

	private function _asXml ($data)
	{
		$response = \Craft::$app->getResponse();
		$response->content = $data;
		$response->format = Response::FORMAT_XML;

		return $response;
	}

}


<?php
/**
 * SEO for Craft
 *
 * @link      https://ethercreative.co.uk
 * @copyright Copyright (c) 2019 Ether Creative
 */

namespace ether\seo\services;

use craft\db\Query;
use craft\base\Component;
use ether\seo\fields\SeoField;
use ether\seo\jobs\UpgradeSeoDataJob;

/**
 * Class UpgradeService
 *
 * @author  Ether Creative
 * @package ether\seo\services
 */
class UpgradeService extends Component
{

	public function toNewDataFormat ()
	{
		$craft = \Craft::$app;

		// 1. For each SEO field, update the settings to the template
		$fields = $this->_getAllSeoFields();

		$fields                = array_map(
			function ($field) use ($craft) {
				return $craft->fields->createField($field);
			}, $fields
		);
		$suffixesByFieldHandle = [];

		$siteName = $craft->sites->primarySite->name;

		/** @var SeoField $field */
		foreach ($fields as $field) if ($field->titleSuffix !== null)
		{
			// If `- Current Prefix` contains the site name, replace with `{{siteName}}`
			$suffix = $field->titleSuffix;

			if (strpos($suffix, $siteName))
				$suffix = str_replace($siteName, '{{siteName}}', $suffix);

			$field->title = [
				[
					'key'      => '1',
					'template' => '{title}',
					'locked'   => false,
				]
			];

			if ($suffix !== '')
			{
				$field->title[] = [
					'key'      => '2',
					'template' => ' ' . $suffix,
					'locked'   => true,
				];

				$suffixesByFieldHandle[$field->handle] =
					' ' . $field->titleSuffix;
			}
			else
			{
				$suffixesByFieldHandle[$field->handle] = '';
			}

			// '[{title}] [- Current Prefix]' (or flipped if suffixAsPrefix is true)
			if ($field->suffixAsPrefix && $suffix !== '')
			{
				$field->title                =
					array_reverse($field->title, false);
				$field->title[0]['key']      = '1';
				$field->title[1]['key']      = '2';
				$field->title[1]['template'] = $suffix . ' ';

				$suffixesByFieldHandle[$field->handle] =
					$field->titleSuffix . ' ';
			}

			$craft->fields->saveField($field);
		}

		// 2. Queue re-save of all elements that have an SEO field
		$layouts = $this->_getAllElementTypesAndIdsThatHaveSEOFields();

		foreach ($layouts as $type => $data)
		{
			$suffix = '';
			if (array_key_exists($data['handle'], $suffixesByFieldHandle))
				$suffix = $suffixesByFieldHandle[$data['handle']];

			$craft->queue->push(
				new UpgradeSeoDataJob(
					[
						'elementType' => $type,
						'criteria'    => [
							'id' => $data['ids']
						],
						'handle'      => $data['handle'],
						'suffix'      => $suffix,
					]
				)
			);
		}
	}

	// Helpers
	// =========================================================================

	private function _getAllSeoFields ()
	{
		return (new Query())
			->select(
				[
					'fields.id',
					'fields.dateCreated',
					'fields.dateUpdated',
					'fields.groupId',
					'fields.name',
					'fields.handle',
					'fields.context',
					'fields.instructions',
					'fields.translationMethod',
					'fields.translationKeyFormat',
					'fields.type',
					'fields.settings',
				]
			)
			->from(['{{%fields}} fields'])
			->where(['type' => SeoField::class])
			->orderBy(['fields.name' => SORT_ASC, 'fields.handle' => SORT_ASC])
			->all();
	}

	private function _getAllElementTypesAndIdsThatHaveSEOFields ()
	{
		$results = (new Query())
			->select(['el.type', 'el.id', 'fields.handle'])
			->from(['{{%fields}} fields'])
			->innerJoin(
				'{{%fieldlayoutfields}} flf',
				'[[flf.fieldId]] = [[fields.id]]'
			)
			->innerJoin(
				'{{%fieldlayouts}} fl',
				'[[fl.id]] = [[flf.layoutId]]'
			)
			->innerJoin(
				'{{%elements}} el',
				'[[el.fieldLayoutId]] = [[flf.layoutId]] AND [[el.type]] = [[fl.type]]'
			)
			->where(['fields.type' => SeoField::class])
			->all();

		return array_reduce(
			$results, function ($a, $b) {
			if (!array_key_exists($b['type'], $a))
				$a[$b['type']] = [
					'ids'    => [],
					'handle' => $b['handle'],
				];

			$a[$b['type']]['ids'][] = $b['id'];

			return $a;
		}, []
		);
	}

}


<?php
/**
 * SEO for Craft CMS
 *
 * @link      https://ethercreative.co.uk
 * @copyright Copyright (c) 2018 Ether Creative
 */

namespace ether\seo\models\data;

use craft\elements\Asset;
use ether\seo\models\Settings;
use ether\seo\Seo;
use yii\base\BaseObject;

function get_public_properties ($class) {
	return get_object_vars($class);
}

/**
 * Class SocialData
 *
 * @author  Ether Creative
 * @package ether\seo\models\data
 */
class SocialData extends BaseObject
{

	// Properties
	// =========================================================================

	// Properties: Public
	// -------------------------------------------------------------------------

	/** @var string */
	public $handle = '';

	/** @var string */
	public $title = '';

	/** @var Asset|array|null */
	public $image = null;

	/** @var string */
	public $description = '';

	// Properties: Private
	// -------------------------------------------------------------------------

	/** @var array */
	private $_fallback;

	/** @var string */
	private $_network;

	// Constructor
	// =========================================================================

	public function __construct (string $network, array $fallback = null, array $config = [])
	{
		$this->_network = $network;
		$this->_fallback = $fallback;

		parent::__construct($config);
	}

	// Init
	// =========================================================================

	public function init ()
	{
		/** @var Settings $seoSettings */
		$seoSettings = Seo::$i->getSettings();

		// Fallbacks
		foreach (get_public_properties($this) as $key => $value)
			if (empty($value) && array_key_exists($key, $this->_fallback))
				$this->$key = $this->_fallback[$key];

		// Convert JSON decoded array to an Asset
		$image = $this->image;
		if (is_array($image)) $image = $image['id'];
		if (!($image instanceof Asset))
			$this->image = \Craft::$app->assets->getAssetById((int) $image);

		// Network Specific
		switch ($this->_network)
		{
			case 'facebook':
				$this->handle = $seoSettings->facebookAppId;
				break;
			case 'twitter':
				$this->handle = $seoSettings->twitterHandle;
				break;
		}
	}

}